Create MeshCentral workspace
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.3 - Profile editor foundation
|
||||
- Added Theme Studio profile storage with a locked MeshCentral Default profile and editable cloned theme profiles.
|
||||
- Added profile dropdown selection, clone current profile, save profile, and export JSON actions.
|
||||
- Replaced the raw inventory-first screen with grouped Brand, Colors, Typography, Layout, Icons, and Advanced controls.
|
||||
- Added dropdown/select controls for fonts, density, icon set, icon size, spacing-style values, and standard color pickers for color fields.
|
||||
- Kept Apply and startup CSS hook controls disabled until generated CSS, rollback, and service-load behavior are implemented.
|
||||
|
||||
## 0.0.2 - MeshCentral-safe runtime identity
|
||||
- Changed the MeshCentral runtime `shortName` to `gpthemestudio` so MeshCentral's alphanumeric admin route guard can open the backend panel.
|
||||
- Renamed the runtime entrypoint to `gpthemestudio.js` and aligned the JavaScript export with the MeshCentral loader.
|
||||
- Preserved the GracePress catalog slug `gracepress-theme-studio` and the existing catalog facade URLs.
|
||||
|
||||
## 0.0.1 - Default theme inventory
|
||||
- Created the initial GracePress Theme Studio MeshCentral plugin.
|
||||
- Captured the current classic MeshCentral UI as a locked `MeshCentral Default` theme profile.
|
||||
- Added a backend admin panel for reviewing theme variables and future theming capabilities.
|
||||
- Kept this first release read-only; it does not inject CSS, alter templates, or modify MeshCentral core files.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "GracePress Theme Studio",
|
||||
"shortName": "gpthemestudio",
|
||||
"version": "0.0.3",
|
||||
"description": "MeshCentral UI theme profile editor for GracePress-managed MeshCentral servers.",
|
||||
"author": "Jason Eugene Garrison",
|
||||
"homepage": "https://christit.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress-theme-studio"
|
||||
},
|
||||
"hasAdminPanel": true,
|
||||
"main": "gpthemestudio.js",
|
||||
"configUrl": "https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json",
|
||||
"downloadUrl": "https://christit.com/gracepress/gracepress-theme-studio.download.php",
|
||||
"changelogUrl": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress-theme-studio",
|
||||
"versionHistoryUrl": "https://christit.com/gracepress/gracepress-theme-studio.tags.json",
|
||||
"meshCentralCompat": ">=1.1.0"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "meshcentral-default",
|
||||
"name": "MeshCentral Default",
|
||||
"description": "Captured baseline for the current Christ IT Connections MeshCentral classic UI before GracePress theming is applied.",
|
||||
"locked": true,
|
||||
"capturedAt": "2026-06-27",
|
||||
"source": "GracePress Theme Studio initial default profile",
|
||||
"variables": {
|
||||
"brand.title": "Christ IT Connections",
|
||||
"brand.subtitle": "mesh.christit.com",
|
||||
"brand.logoUrl": "",
|
||||
"brand.footerText": "Terms & Privacy",
|
||||
"font.family": "Arial, Helvetica, sans-serif",
|
||||
"font.baseSize": "16px",
|
||||
"font.smallSize": "12px",
|
||||
"font.headingSize": "32px",
|
||||
"layout.mode": "classic-leftbar",
|
||||
"layout.density": "default",
|
||||
"layout.contentMaxWidth": "none",
|
||||
"layout.leftbarWidth": "75px",
|
||||
"layout.iconRailWidth": "75px",
|
||||
"layout.footerHeight": "48px",
|
||||
"layout.borderRadius": "0px",
|
||||
"color.pageBackground": "#000000",
|
||||
"color.headerBackground": "#003a70",
|
||||
"color.headerText": "#d7d7d7",
|
||||
"color.headerAccent": "#ffffff",
|
||||
"color.leftbarBackground": "#1f1f2a",
|
||||
"color.leftbarActiveBackground": "#8bc8ff",
|
||||
"color.leftbarActiveBorder": "#66c27a",
|
||||
"color.sidebarBackground": "#144c83",
|
||||
"color.sidebarButtonBackground": "#06417a",
|
||||
"color.sidebarButtonActiveBackground": "#0d5ea8",
|
||||
"color.tabBackground": "#8b8b8b",
|
||||
"color.tabActiveBackground": "#004b8d",
|
||||
"color.tabText": "#ffffff",
|
||||
"color.panelBackground": "#000000",
|
||||
"color.panelText": "#d7d7d7",
|
||||
"color.tableHeaderText": "#ffffff",
|
||||
"color.tableRowText": "#d7d7d7",
|
||||
"color.link": "#5dade2",
|
||||
"color.success": "#00cc44",
|
||||
"color.warning": "#b45309",
|
||||
"color.danger": "#cc3333",
|
||||
"color.modalOverlay": "rgba(0,0,0,0.5)",
|
||||
"color.modalBackground": "#ffffff",
|
||||
"color.modalText": "#111111",
|
||||
"color.modalBorder": "#d9dde5",
|
||||
"button.primaryBackground": "#007bff",
|
||||
"button.primaryText": "#ffffff",
|
||||
"button.secondaryBackground": "#f4f4f6",
|
||||
"button.secondaryText": "#111111",
|
||||
"button.border": "#aeb4bf",
|
||||
"table.border": "#303030",
|
||||
"table.headerBackground": "#000000",
|
||||
"table.rowBackground": "#000000",
|
||||
"table.rowAltBackground": "#050505",
|
||||
"icons.set": "meshcentral-classic",
|
||||
"icons.size": "48px",
|
||||
"icons.monochrome": false,
|
||||
"icons.packPath": "",
|
||||
"effects.textShadow": "2px 2px 3px rgba(0,0,0,0.85)",
|
||||
"effects.panelShadow": "none",
|
||||
"effects.modalShadow": "0 0 24px rgba(0,0,0,0.6)",
|
||||
"advanced.customCss": ""
|
||||
},
|
||||
"notes": [
|
||||
"This profile is intentionally descriptive. It records the current classic UI appearance as variables before any global CSS injection exists.",
|
||||
"Values can be refined from MeshCentral CSS inspection in a later pass.",
|
||||
"Do not mutate this profile in-place; clone it into a new theme when applying changes."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plugin Name: GracePress Theme Studio
|
||||
* Description: MeshCentral UI theme inventory and profile editor for GracePress-managed MeshCentral servers.
|
||||
* Version: 0.0.3
|
||||
* Stage: Alpha
|
||||
* Release Channel: Internal
|
||||
* Public Release: No
|
||||
* Public Candidate: No
|
||||
* Category: MeshCentral
|
||||
* Settings Slug: gracepress-theme-studio
|
||||
* WordPress.org Slug:
|
||||
* MeshCentral Short Name: gpthemestudio
|
||||
* MeshCentral Config URL: https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json
|
||||
* MeshCentral Compatibility: >=1.1.0
|
||||
* Requires PHP: 8.0
|
||||
* Icon: 🧩
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const querystring = require('querystring');
|
||||
|
||||
const PLUGIN = {
|
||||
name: 'GracePress Theme Studio',
|
||||
shortName: 'gpthemestudio',
|
||||
version: '0.0.3',
|
||||
};
|
||||
|
||||
const PROFILE_DIR = 'themes';
|
||||
const DEFAULT_PROFILE_ID = 'meshcentral-default';
|
||||
const ACTIVE_FILE = 'active-theme.json';
|
||||
|
||||
const CONTROL_GROUPS = [
|
||||
{
|
||||
id: 'brand',
|
||||
label: 'Brand',
|
||||
description: 'Visible identity and non-CSS presentation values.',
|
||||
fields: [
|
||||
{ key: 'brand.title', label: 'Site title', type: 'text' },
|
||||
{ key: 'brand.subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ key: 'brand.logoUrl', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
|
||||
{ key: 'brand.footerText', label: 'Footer text', type: 'text', placeholder: 'Terms & Privacy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'color',
|
||||
label: 'Colors',
|
||||
description: 'Primary MeshCentral chrome, panel, link, and state colors.',
|
||||
fields: [
|
||||
{ key: 'color.headerBackground', label: 'Header background', type: 'color' },
|
||||
{ key: 'color.headerText', label: 'Header text', type: 'color' },
|
||||
{ key: 'color.leftbarBackground', label: 'Icon rail background', type: 'color' },
|
||||
{ key: 'color.sidebarBackground', label: 'Sidebar background', type: 'color' },
|
||||
{ key: 'color.tabActiveBackground', label: 'Active tab', type: 'color' },
|
||||
{ key: 'color.panelBackground', label: 'Panel background', type: 'color' },
|
||||
{ key: 'color.panelText', label: 'Panel text', type: 'color' },
|
||||
{ key: 'color.link', label: 'Links', type: 'color' },
|
||||
{ key: 'color.success', label: 'Success', type: 'color' },
|
||||
{ key: 'color.warning', label: 'Warning', type: 'color' },
|
||||
{ key: 'color.danger', label: 'Danger', type: 'color' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
label: 'Typography',
|
||||
description: 'Dropdown-driven font decisions instead of blank freehand values.',
|
||||
fields: [
|
||||
{
|
||||
key: 'font.family',
|
||||
label: 'Font family',
|
||||
type: 'select',
|
||||
options: [
|
||||
'Arial, Helvetica, sans-serif',
|
||||
'Inter, Arial, Helvetica, sans-serif',
|
||||
'Segoe UI, Arial, Helvetica, sans-serif',
|
||||
'Roboto, Arial, Helvetica, sans-serif',
|
||||
'Georgia, serif',
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
],
|
||||
},
|
||||
{ key: 'font.baseSize', label: 'Base size', type: 'select', options: ['14px', '15px', '16px', '17px', '18px'] },
|
||||
{ key: 'font.headingSize', label: 'Heading size', type: 'select', options: ['24px', '28px', '32px', '36px', '40px'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
description: 'Stable layout tokens for later CSS generation.',
|
||||
fields: [
|
||||
{ key: 'layout.density', label: 'Density', type: 'select', options: ['compact', 'default', 'comfortable'] },
|
||||
{ key: 'layout.leftbarWidth', label: 'Icon rail width', type: 'select', options: ['60px', '68px', '75px', '84px', '96px'] },
|
||||
{ key: 'layout.borderRadius', label: 'Border radius', type: 'select', options: ['0px', '3px', '5px', '8px'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'icons',
|
||||
label: 'Icons',
|
||||
description: 'Icon pack selection. Custom pack upload/generation comes after the mapping is proven.',
|
||||
fields: [
|
||||
{ key: 'icons.set', label: 'Icon set', type: 'select', options: ['meshcentral-classic', 'gracepress-classic', 'gracepress-flat', 'custom-pack'] },
|
||||
{ key: 'icons.size', label: 'Icon size', type: 'select', options: ['40px', '48px', '56px', '64px'] },
|
||||
{ key: 'icons.packPath', label: 'Custom pack path', type: 'text', placeholder: '/path/to/icons' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
description: 'Recorded but not globally injected until rollback and startup loading are built.',
|
||||
fields: [
|
||||
{ key: 'advanced.customCss', label: 'Custom CSS notes', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports.gpthemestudio = function(parent) {
|
||||
const obj = { parent };
|
||||
obj.exports = [];
|
||||
|
||||
ensureProfileStore(parent);
|
||||
console.log(`${PLUGIN.name} loaded.`);
|
||||
|
||||
obj.handleAdminReq = function(req, res, user) {
|
||||
handleAdminRequest(parent, req, res, user);
|
||||
};
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
function handleAdminRequest(parent, req, res, user) {
|
||||
if (req && req.method === 'POST') {
|
||||
readBody(req, function(err, body) {
|
||||
const username = getUsername(user);
|
||||
let notice = '';
|
||||
let error = '';
|
||||
|
||||
if (err) {
|
||||
error = err.message;
|
||||
} else {
|
||||
const result = handleAction(parent, body);
|
||||
notice = result.notice || '';
|
||||
error = result.error || '';
|
||||
}
|
||||
|
||||
const state = loadState(parent);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderAdmin(username, state, notice, error));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = loadState(parent, req);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderAdmin(getUsername(user), state, '', ''));
|
||||
}
|
||||
|
||||
function readBody(req, done) {
|
||||
let body = '';
|
||||
req.on('data', function(chunk) {
|
||||
body += chunk;
|
||||
if (body.length > 262144) req.destroy(new Error('Request body too large.'));
|
||||
});
|
||||
req.on('end', function() {
|
||||
try {
|
||||
done(null, querystring.parse(body));
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
req.on('error', done);
|
||||
}
|
||||
|
||||
function handleAction(parent, data) {
|
||||
const action = getPostedValue(data.action);
|
||||
if (action === 'clone') return cloneProfile(parent, data);
|
||||
if (action === 'save') return saveProfile(parent, data);
|
||||
if (action === 'select') return selectActiveProfile(parent, data.profileId);
|
||||
if (action === 'export') return exportProfile(parent, data.profileId);
|
||||
return { error: 'Unknown Theme Studio action.' };
|
||||
}
|
||||
|
||||
function cloneProfile(parent, data) {
|
||||
const base = getProfileById(parent, String(data.sourceProfileId || DEFAULT_PROFILE_ID)) || loadDefaultTheme(parent);
|
||||
const name = cleanProfileName(data.newProfileName || 'Christ IT Theme');
|
||||
const id = uniqueProfileId(parent, slugify(name));
|
||||
const clone = JSON.parse(JSON.stringify(base));
|
||||
|
||||
clone.id = id;
|
||||
clone.name = name;
|
||||
clone.locked = false;
|
||||
clone.capturedAt = new Date().toISOString().slice(0, 10);
|
||||
clone.source = `Cloned from ${base.name || base.id || DEFAULT_PROFILE_ID}`;
|
||||
clone.description = `Editable theme profile cloned from ${base.name || 'MeshCentral Default'}.`;
|
||||
clone.notes = ['Editable GracePress Theme Studio profile.'];
|
||||
clone.branding = clone.branding || {};
|
||||
clone.generated = clone.generated || {};
|
||||
|
||||
writeProfile(parent, clone);
|
||||
writeActiveProfile(parent, id);
|
||||
return { notice: `Created editable theme profile "${name}".` };
|
||||
}
|
||||
|
||||
function saveProfile(parent, data) {
|
||||
const profileId = String(data.profileId || '');
|
||||
const profile = getProfileById(parent, profileId);
|
||||
if (!profile) return { error: 'Selected profile could not be found.' };
|
||||
if (profile.locked) return { error: 'The default MeshCentral profile is locked. Clone it before editing.' };
|
||||
|
||||
profile.name = cleanProfileName(data.profileName || profile.name || profile.id);
|
||||
profile.description = String(data.profileDescription || profile.description || '');
|
||||
profile.updatedAt = new Date().toISOString();
|
||||
profile.variables = profile.variables || {};
|
||||
|
||||
getEditableFields().forEach(function(field) {
|
||||
const formKey = formFieldName(field.key);
|
||||
if (Object.prototype.hasOwnProperty.call(data, formKey)) {
|
||||
profile.variables[field.key] = normalizeFieldValue(field, data[formKey]);
|
||||
}
|
||||
});
|
||||
|
||||
writeProfile(parent, profile);
|
||||
writeActiveProfile(parent, profile.id);
|
||||
return { notice: `Saved "${profile.name}".` };
|
||||
}
|
||||
|
||||
function selectActiveProfile(parent, profileId) {
|
||||
const profile = getProfileById(parent, String(profileId || ''));
|
||||
if (!profile) return { error: 'Selected profile could not be found.' };
|
||||
writeActiveProfile(parent, profile.id);
|
||||
return { notice: `Selected "${profile.name}" as the active editing profile.` };
|
||||
}
|
||||
|
||||
function exportProfile(parent, profileId) {
|
||||
const profile = getProfileById(parent, String(profileId || ''));
|
||||
if (!profile) return { error: 'Selected profile could not be found.' };
|
||||
const exportDir = path.join(getProfileDir(parent), 'exports');
|
||||
fs.mkdirSync(exportDir, { recursive: true });
|
||||
const file = path.join(exportDir, `${safeFileName(profile.id)}-${Date.now()}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify(profile, null, 2) + '\n', 'utf8');
|
||||
return { notice: `Exported "${profile.name}" to ${file}.` };
|
||||
}
|
||||
|
||||
function ensureProfileStore(parent) {
|
||||
const dir = getProfileDir(parent);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const defaultFile = path.join(dir, `${DEFAULT_PROFILE_ID}.json`);
|
||||
if (!fs.existsSync(defaultFile)) {
|
||||
writeProfile(parent, loadDefaultTheme(parent));
|
||||
}
|
||||
if (!fs.existsSync(path.join(dir, ACTIVE_FILE))) {
|
||||
writeActiveProfile(parent, DEFAULT_PROFILE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
function loadState(parent, req) {
|
||||
ensureProfileStore(parent);
|
||||
const profiles = loadProfiles(parent);
|
||||
const requestedId = getRequestProfileId(req);
|
||||
const activeId = requestedId || readActiveProfile(parent) || DEFAULT_PROFILE_ID;
|
||||
const active = profiles.find(function(profile) { return profile.id === activeId; }) || profiles[0] || loadDefaultTheme(parent);
|
||||
return {
|
||||
profiles,
|
||||
active,
|
||||
activeId: active.id,
|
||||
storagePath: getProfileDir(parent),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestProfileId(req) {
|
||||
if (!req || !req.url || req.url.indexOf('?') === -1) return '';
|
||||
const query = querystring.parse(req.url.split('?').slice(1).join('?'));
|
||||
return String(query.profileId || '');
|
||||
}
|
||||
|
||||
function loadProfiles(parent) {
|
||||
const dir = getProfileDir(parent);
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter(function(file) { return file.endsWith('.json') && file !== ACTIVE_FILE; })
|
||||
.sort();
|
||||
const profiles = files.map(function(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
profiles.sort(function(left, right) {
|
||||
if (left.id === DEFAULT_PROFILE_ID) return -1;
|
||||
if (right.id === DEFAULT_PROFILE_ID) return 1;
|
||||
return String(left.name || left.id).localeCompare(String(right.name || right.id));
|
||||
});
|
||||
|
||||
return profiles.length ? profiles : [loadDefaultTheme(parent)];
|
||||
}
|
||||
|
||||
function getProfileById(parent, profileId) {
|
||||
return loadProfiles(parent).find(function(profile) { return profile.id === profileId; }) || null;
|
||||
}
|
||||
|
||||
function getProfileDir(parent) {
|
||||
return path.join(__dirname, PROFILE_DIR);
|
||||
}
|
||||
|
||||
function readActiveProfile(parent) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), 'utf8')).activeProfileId || DEFAULT_PROFILE_ID;
|
||||
} catch (err) {
|
||||
return DEFAULT_PROFILE_ID;
|
||||
}
|
||||
}
|
||||
|
||||
function writeActiveProfile(parent, profileId) {
|
||||
fs.writeFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), JSON.stringify({
|
||||
activeProfileId: profileId || DEFAULT_PROFILE_ID,
|
||||
selectedAt: new Date().toISOString(),
|
||||
applied: false,
|
||||
}, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function writeProfile(parent, profile) {
|
||||
fs.mkdirSync(getProfileDir(parent), { recursive: true });
|
||||
fs.writeFileSync(path.join(getProfileDir(parent), `${safeFileName(profile.id)}.json`), JSON.stringify(profile, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function loadDefaultTheme(parent) {
|
||||
const candidates = [
|
||||
path.join(__dirname, 'default-theme.json'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const theme = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
theme.id = DEFAULT_PROFILE_ID;
|
||||
theme.name = theme.name || 'MeshCentral Default';
|
||||
theme.locked = true;
|
||||
theme.variables = withProfileDefaults(theme.variables || {});
|
||||
return theme;
|
||||
}
|
||||
} catch (err) {
|
||||
return fallbackDefaultTheme(file, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackDefaultTheme('', '');
|
||||
}
|
||||
|
||||
function fallbackDefaultTheme(source, error) {
|
||||
return {
|
||||
schema: 1,
|
||||
id: DEFAULT_PROFILE_ID,
|
||||
name: 'MeshCentral Default',
|
||||
description: error ? 'Default theme profile could not be loaded.' : 'Default theme profile is missing from this plugin package.',
|
||||
locked: true,
|
||||
capturedAt: '',
|
||||
source,
|
||||
variables: withProfileDefaults({}),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function withProfileDefaults(variables) {
|
||||
const defaults = {
|
||||
'brand.logoUrl': '',
|
||||
'brand.footerText': 'Terms & Privacy',
|
||||
'icons.packPath': '',
|
||||
'advanced.customCss': '',
|
||||
};
|
||||
return Object.assign({}, defaults, variables);
|
||||
}
|
||||
|
||||
function renderAdmin(username, state, notice, error) {
|
||||
const profile = state.active || loadDefaultTheme();
|
||||
const variables = withProfileDefaults(profile.variables || {});
|
||||
const locked = !!profile.locked;
|
||||
const profiles = state.profiles || [];
|
||||
const rawGroups = groupVariables(variables);
|
||||
const rawRows = renderRawInventory(rawGroups);
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(PLUGIN.name)}</title>
|
||||
<style>
|
||||
:root {
|
||||
--gp-bg: #f5f7fa;
|
||||
--gp-panel: #ffffff;
|
||||
--gp-ink: #1f2937;
|
||||
--gp-muted: #667085;
|
||||
--gp-line: #d8dee8;
|
||||
--gp-accent: #0f5e9c;
|
||||
--gp-accent-2: #087f5b;
|
||||
--gp-warn: #b45309;
|
||||
--gp-danger: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: var(--gp-bg); color: var(--gp-ink); font-family: Arial, Helvetica, sans-serif; }
|
||||
.wrap { max-width: 1180px; }
|
||||
.hero { border-left: 5px solid var(--gp-accent); padding: 0 0 0 16px; margin-bottom: 18px; }
|
||||
h1 { margin: 0 0 6px; font-size: 24px; }
|
||||
h2 { margin: 0 0 12px; font-size: 17px; }
|
||||
h3 { margin: 0 0 8px; font-size: 15px; }
|
||||
p { line-height: 1.45; }
|
||||
.muted { color: var(--gp-muted); }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; margin: 16px 0; }
|
||||
.metric, .panel { background: var(--gp-panel); border: 1px solid var(--gp-line); border-radius: 6px; padding: 14px; }
|
||||
.metric .label { color: var(--gp-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
.metric .value { font-size: 18px; font-weight: 700; margin-top: 5px; }
|
||||
.panels { display: grid; grid-template-columns: 1fr; gap: 14px; }
|
||||
.profilebar { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: end; }
|
||||
.notice, .error { border-radius: 5px; padding: 9px 11px; margin: 0 0 12px; }
|
||||
.notice { border: 1px solid #9bd7bd; background: #edfdf5; color: #087f5b; }
|
||||
.error { border: 1px solid #f2aca8; background: #fff1f0; color: var(--gp-danger); }
|
||||
label { display: block; color: #374151; font-weight: 700; font-size: 12px; margin: 0 0 5px; text-transform: uppercase; }
|
||||
input, select, textarea { width: 100%; border: 1px solid #bfc7d4; border-radius: 5px; padding: 8px; font: inherit; color: #111827; background: #fff; }
|
||||
input[type="color"] { min-height: 38px; padding: 3px; }
|
||||
textarea { min-height: 92px; resize: vertical; }
|
||||
input[readonly], textarea[readonly], select:disabled { background: #f3f5f8; color: #6b7280; }
|
||||
.section-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 12px; }
|
||||
.field { margin-bottom: 10px; }
|
||||
.field.color { display: grid; grid-template-columns: 54px 1fr; gap: 8px; align-items: end; }
|
||||
.field.color label { grid-column: 1 / -1; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
button { border: 1px solid var(--gp-line); background: #eef2f7; color: #111827; border-radius: 5px; padding: 8px 11px; cursor: pointer; }
|
||||
button.primary { background: var(--gp-accent); border-color: var(--gp-accent); color: #fff; }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { border-top: 1px solid var(--gp-line); padding: 9px 10px; text-align: left; vertical-align: middle; }
|
||||
th { background: #f0f3f8; color: #374151; font-size: 12px; text-transform: uppercase; }
|
||||
code { background: #eef2f7; padding: 2px 5px; border-radius: 3px; }
|
||||
.chip { display: inline-block; border: 1px solid var(--gp-line); background: #f8fafc; border-radius: 999px; padding: 3px 8px; font-size: 12px; color: #334155; }
|
||||
.chip.ok { border-color: #9bd7bd; color: var(--gp-accent-2); background: #edfdf5; }
|
||||
.chip.warn { border-color: #f2c98d; color: var(--gp-warn); background: #fff7ed; }
|
||||
.swatch { display: inline-block; width: 18px; height: 18px; border: 1px solid #9ca3af; border-radius: 3px; vertical-align: middle; margin-right: 8px; background: var(--swatch); }
|
||||
.raw { max-height: 360px; overflow: auto; border: 1px solid var(--gp-line); border-radius: 5px; }
|
||||
@media (max-width: 840px) { .grid, .section-grid, .profilebar { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<h1>${escapeHtml(PLUGIN.name)}</h1>
|
||||
<div class="muted">Profile editor baseline for MeshCentral UI theming. Logged in as ${escapeHtml(username)}.</div>
|
||||
</div>
|
||||
|
||||
${notice ? `<div class="notice">${escapeHtml(notice)}</div>` : ''}
|
||||
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
||||
|
||||
<div class="grid">
|
||||
<div class="metric"><div class="label">Plugin</div><div class="value">${escapeHtml(PLUGIN.version)}</div></div>
|
||||
<div class="metric"><div class="label">Profile</div><div class="value">${escapeHtml(profile.name || '')}</div></div>
|
||||
<div class="metric"><div class="label">Variables</div><div class="value">${Object.keys(variables).length}</div></div>
|
||||
<div class="metric"><div class="label">Mode</div><div class="value">${locked ? '<span class="chip ok">Locked default</span>' : '<span class="chip warn">Editable</span>'}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panels">
|
||||
<section class="panel">
|
||||
<form method="post" class="profilebar">
|
||||
<input type="hidden" name="action" value="select">
|
||||
<div>
|
||||
<label for="profileId">Theme profile</label>
|
||||
<select id="profileId" name="profileId">${renderProfileOptions(profiles, profile.id)}</select>
|
||||
</div>
|
||||
<button class="primary" type="submit">Load Profile</button>
|
||||
<button type="submit" name="action" value="export">Export JSON</button>
|
||||
</form>
|
||||
<form method="post" class="actions">
|
||||
<input type="hidden" name="action" value="clone">
|
||||
<input type="hidden" name="sourceProfileId" value="${escapeAttr(profile.id)}">
|
||||
<input name="newProfileName" value="${escapeAttr(suggestCloneName(profile.name))}" aria-label="New profile name">
|
||||
<button class="primary" type="submit">Clone Current Profile</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form method="post" class="panel">
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="profileId" value="${escapeAttr(profile.id)}">
|
||||
<h2>Profile Editor</h2>
|
||||
<p class="muted">${locked ? 'This is the locked MeshCentral Default profile. Clone it to create an editable theme.' : 'Edit values, save the profile, then export or apply after the global CSS hook is built.'}</p>
|
||||
<div class="section-grid">
|
||||
<div class="field">
|
||||
<label for="profileName">Profile name</label>
|
||||
<input id="profileName" name="profileName" value="${escapeAttr(profile.name || '')}" ${locked ? 'readonly' : ''}>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="profileDescription">Description</label>
|
||||
<input id="profileDescription" name="profileDescription" value="${escapeAttr(profile.description || '')}" ${locked ? 'readonly' : ''}>
|
||||
</div>
|
||||
</div>
|
||||
${CONTROL_GROUPS.map(function(group) { return renderControlGroup(group, variables, locked); }).join('')}
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" ${locked ? 'disabled' : ''}>Save Profile</button>
|
||||
<button type="button" disabled>Apply Theme</button>
|
||||
<button type="button" disabled>Install Startup CSS Hook</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Implementation Boundary</h2>
|
||||
<p class="muted">Theme Studio now saves editable profiles. Apply is intentionally disabled until the generated CSS hook, startup reapply behavior, and rollback marker are implemented.</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>ID</th><td><code>${escapeHtml(profile.id || '')}</code></td></tr>
|
||||
<tr><th>Storage</th><td><code>${escapeHtml(state.storagePath || '')}</code></td></tr>
|
||||
<tr><th>Source</th><td>${escapeHtml(profile.source || '')}</td></tr>
|
||||
<tr><th>Updated</th><td>${escapeHtml(profile.updatedAt || profile.capturedAt || '')}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Raw Variable Inventory</h2>
|
||||
<p class="muted">Kept for audit and future mapping work. The editor above is the primary workflow.</p>
|
||||
<div class="raw">${rawRows || '<p class="muted">No variables were found.</p>'}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderProfileOptions(profiles, activeId) {
|
||||
return profiles.map(function(profile) {
|
||||
const label = `${profile.name || profile.id}${profile.locked ? ' (locked)' : ''}`;
|
||||
return `<option value="${escapeAttr(profile.id)}" ${profile.id === activeId ? 'selected' : ''}>${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderControlGroup(group, variables, locked) {
|
||||
const fields = group.fields.map(function(field) {
|
||||
return renderField(field, variables[field.key], locked);
|
||||
}).join('');
|
||||
|
||||
return `<section>
|
||||
<h3>${escapeHtml(group.label)}</h3>
|
||||
<p class="muted">${escapeHtml(group.description)}</p>
|
||||
<div class="section-grid">${fields}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderField(field, value, locked) {
|
||||
const name = formFieldName(field.key);
|
||||
const id = `field-${name}`;
|
||||
const current = value === undefined || value === null ? '' : String(value);
|
||||
const disabled = locked ? 'disabled' : '';
|
||||
const readonly = locked ? 'readonly' : '';
|
||||
const fieldClass = field.type === 'color' ? 'field color' : 'field';
|
||||
let control = '';
|
||||
|
||||
if (field.type === 'select') {
|
||||
control = `<select id="${escapeAttr(id)}" name="${escapeAttr(name)}" ${disabled}>${renderSelectOptions(field.options || [], current)}</select>`;
|
||||
} else if (field.type === 'textarea') {
|
||||
control = `<textarea id="${escapeAttr(id)}" name="${escapeAttr(name)}" ${readonly}>${escapeHtml(current)}</textarea>`;
|
||||
} else if (field.type === 'color') {
|
||||
const color = isHexColor(current) ? current : '#000000';
|
||||
control = `<input type="color" id="${escapeAttr(id)}-color" name="${escapeAttr(name)}" value="${escapeAttr(color)}" ${disabled}><input type="text" value="${escapeAttr(current)}" ${readonly} aria-label="${escapeAttr(field.label)} text value">`;
|
||||
} else {
|
||||
control = `<input id="${escapeAttr(id)}" name="${escapeAttr(name)}" value="${escapeAttr(current)}" placeholder="${escapeAttr(field.placeholder || '')}" ${readonly}>`;
|
||||
}
|
||||
|
||||
return `<div class="${fieldClass}"><label for="${escapeAttr(id)}">${escapeHtml(field.label)}</label>${control}</div>`;
|
||||
}
|
||||
|
||||
function renderSelectOptions(options, current) {
|
||||
const seen = new Set();
|
||||
const all = options.slice();
|
||||
if (current && all.indexOf(current) === -1) all.unshift(current);
|
||||
return all.filter(function(option) {
|
||||
if (seen.has(option)) return false;
|
||||
seen.add(option);
|
||||
return true;
|
||||
}).map(function(option) {
|
||||
return `<option value="${escapeAttr(option)}" ${option === current ? 'selected' : ''}>${escapeHtml(option)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRawInventory(groups) {
|
||||
const order = ['brand', 'font', 'layout', 'color', 'button', 'table', 'icons', 'effects', 'advanced', 'other'];
|
||||
return order.filter(function(group) { return groups[group] && groups[group].length; }).map(function(group) {
|
||||
const body = groups[group].map(function(row) {
|
||||
return `<tr><td><code>${escapeHtml(row.key)}</code></td><td>${renderValue(row.value)}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<h3>${escapeHtml(titleCase(group))}</h3><table><thead><tr><th>Variable</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getEditableFields() {
|
||||
return CONTROL_GROUPS.reduce(function(fields, group) {
|
||||
return fields.concat(group.fields);
|
||||
}, []);
|
||||
}
|
||||
|
||||
function groupVariables(variables) {
|
||||
const groups = {};
|
||||
Object.keys(variables).sort().forEach(function(key) {
|
||||
const dot = key.indexOf('.');
|
||||
const group = dot > 0 ? key.substring(0, dot) : 'other';
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push({ key, value: variables[key] });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderValue(value) {
|
||||
const text = String(value);
|
||||
const color = isColorValue(text) ? `<span class="swatch" style="--swatch:${escapeAttr(text)}"></span>` : '';
|
||||
return `${color}<code>${escapeHtml(text)}</code>`;
|
||||
}
|
||||
|
||||
function normalizeFieldValue(field, value) {
|
||||
value = getPostedValue(value);
|
||||
value = String(value || '').trim();
|
||||
if (field.type === 'color' && !isHexColor(value)) return '#000000';
|
||||
return value;
|
||||
}
|
||||
|
||||
function getPostedValue(value) {
|
||||
if (Array.isArray(value)) return String(value[value.length - 1] || '');
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
function formFieldName(key) {
|
||||
return `var_${String(key).replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
}
|
||||
|
||||
function uniqueProfileId(parent, base) {
|
||||
base = base || 'theme';
|
||||
const existing = new Set(loadProfiles(parent).map(function(profile) { return profile.id; }));
|
||||
if (!existing.has(base)) return base;
|
||||
let index = 2;
|
||||
while (existing.has(`${base}-${index}`)) index += 1;
|
||||
return `${base}-${index}`;
|
||||
}
|
||||
|
||||
function cleanProfileName(value) {
|
||||
const name = String(value || '').trim().replace(/\s+/g, ' ');
|
||||
return name || 'Untitled Theme';
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'theme';
|
||||
}
|
||||
|
||||
function safeFileName(value) {
|
||||
return String(value || 'theme').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
function suggestCloneName(name) {
|
||||
if (!name || name === 'MeshCentral Default') return 'Christ IT Theme';
|
||||
return `${name} Copy`;
|
||||
}
|
||||
|
||||
function getUsername(user) {
|
||||
return (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin';
|
||||
}
|
||||
|
||||
function isHexColor(value) {
|
||||
return /^#([0-9a-f]{6})$/i.test(String(value || ''));
|
||||
}
|
||||
|
||||
function isColorValue(value) {
|
||||
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) || /^rgba?\(/i.test(value);
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
return String(value).replace(/[-_]/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function(c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, '`');
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.18 - Theme Studio 0.0.3 catalog seed
|
||||
- Updated the built-in GracePress Theme Studio catalog seed to version `0.0.3`.
|
||||
- Changed the Theme Studio description to match the new profile editor role.
|
||||
- Keeps the MeshCentral package manager authoritative over GracePress-managed plugin rows while preserving unrelated third-party plugin rows.
|
||||
|
||||
## 0.0.17 - GracePress row ownership cleanup
|
||||
- Resolves MeshCentral plugin registry row IDs from `_id`, `id`, or `pluginId` so cleanup works across MeshCentral database adapters.
|
||||
- Archives and removes GracePress-owned legacy rows when their short name no longer matches the GracePress catalog entry.
|
||||
- Archives and removes duplicate GracePress-owned rows for the same managed MeshCentral short name while preserving the active row when one exists.
|
||||
- Reports missing row IDs as explicit sync errors instead of silently leaving obsolete GracePress rows in the MeshCentral plugin catalog.
|
||||
- Keeps cleanup scoped to GracePress-managed plugin entries so unrelated third-party MeshCentral plugins are not touched.
|
||||
|
||||
## 0.0.16 - Legacy row quarantine
|
||||
- Archives and removes obsolete legacy MeshCentral plugin rows such as `gracepress-theme-studio` after preserving their registry document under the live plugin `.old` folder.
|
||||
- Preserves the installed/active status of the corrected `gpthemestudio` row during catalog sync instead of forcing managed rows back to disabled.
|
||||
- Keeps Theme Studio migration in the rolling catalog update path while avoiding duplicate disabled rows in MeshCentral's plugin list.
|
||||
|
||||
## 0.0.15 - MeshCentral-safe Theme Studio seed
|
||||
- Updated the built-in Theme Studio seed to use the MeshCentral-safe runtime short name `gpthemestudio`.
|
||||
- Preserved the public GracePress catalog slug `gracepress-theme-studio` while separating it from MeshCentral's admin-route identity.
|
||||
- Keeps the catalog-manager rolling update path as the migration mechanism for correcting installed MeshCentral plugin rows.
|
||||
- Migrates the legacy `gracepress-theme-studio` MeshCentral row forward to `gpthemestudio` instead of creating a duplicate row.
|
||||
|
||||
## 0.0.14 - Built-in MeshCentral plugin catalog seed
|
||||
- Added a deterministic built-in GracePress MeshCentral plugin catalog seed for `GracePress Theme Studio`.
|
||||
- Changed discovery to write disabled plugin rows through MeshCentral's database helper directly instead of the higher-level plugin wrapper.
|
||||
- Added explicit sync-stage logging so MeshCentral service logs show whether discovery starts, reads existing rows, inserts rows, or fails.
|
||||
- Kept the dynamic catalog URL in metadata for a later pass, but removed runtime catalog fetch as a dependency for exposing the first managed plugin.
|
||||
|
||||
## 0.0.13 - Constructor startup sync
|
||||
- Moved GracePress MeshCentral plugin discovery into the plugin constructor path because MeshCentral does not call `server_startup()` for already-installed plugins during ordinary service reload.
|
||||
- Kept the `server_startup()` hook for install/upgrade compatibility while also scheduling discovery shortly after plugin load.
|
||||
- Added an admin-panel fallback that starts discovery if the connector panel opens before the scheduled sync has run.
|
||||
|
||||
## 0.0.12 - Catalog plugin discovery
|
||||
- Added startup sync from the GracePress `mcplugins` catalog bucket into MeshCentral's plugin registry.
|
||||
- Missing GracePress MeshCentral plugins are now inserted as disabled/available rows, ready for MeshCentral's normal Install action.
|
||||
- Existing disabled rows are refreshed from their current `.mcplugin.json` config while active installed plugins are left alone.
|
||||
- Added backend panel sync status so catalog discovery can be inspected from the connector admin view.
|
||||
|
||||
## 0.0.11 - Facade confirmation update
|
||||
- Published a marker-only update to confirm the MeshCentral git facade shape after the successful `0.0.10` upgrade.
|
||||
- Preserved the MeshCentral-facing `downloadUrl` and `versionHistoryUrl` facade URLs in package config.
|
||||
- Updated the backend panel test marker to `0.0.11`.
|
||||
|
||||
## 0.0.10 - MeshCentral git facade test
|
||||
- Added a marker-only update that uses GracePress-generated MeshCentral git facade URLs.
|
||||
- Switched MeshCentral `downloadUrl` to the no-query `gracepress.download.php` package facade.
|
||||
- Switched MeshCentral `versionHistoryUrl` to `gracepress.tags.json`, shaped like a GitHub tags response with `name` and `url` fields.
|
||||
- Updated the backend panel test marker to `0.0.10`.
|
||||
|
||||
## 0.0.9 - Versioned download URL retest
|
||||
- Published a marker-only update that uses the versioned package URL as MeshCentral `downloadUrl`.
|
||||
- Avoided the cached `gracepress-latest.zip` alias and the nginx path-info download route while validating the built-in Upgrade action.
|
||||
- Updated the backend panel test marker to `0.0.9`.
|
||||
|
||||
## 0.0.8 - Upgrade flow retest
|
||||
- Published a small marker-only update for validating MeshCentral's built-in Upgrade action after the MySQL plugin registry repair.
|
||||
- Updated the backend panel test marker to `0.0.8` so a successful install can be confirmed from the MeshCentral plugin panel.
|
||||
|
||||
## 0.0.7 - Direct MeshCentral latest zip
|
||||
- Replaced the query-string latest-download endpoint with a stable `gracepress-latest.zip` file because MeshCentral's plugin downloader builds requests from `URL.pathname` and drops query strings.
|
||||
- Kept versioned GracePress release artifacts while adding a MeshCentral-safe latest zip alias for the built-in Upgrade action.
|
||||
- Updated package metadata so installed MeshCentral config rows keep using the stable latest zip URL after upgrade.
|
||||
|
||||
## 0.0.6 - Stable MeshCentral upgrade download
|
||||
- Added a GracePress `mcplugin-download` endpoint for MeshCentral's built-in Upgrade action.
|
||||
- Changed the MeshCentral-facing `downloadUrl` to a stable latest-download endpoint because MeshCentral upgrades from the installed DB row, not the freshly fetched latest config object.
|
||||
- Kept the GracePress manifest `download_url` versioned for catalog and rollback clarity.
|
||||
|
||||
## 0.0.5 - Upgrade detection test
|
||||
- Published a small catalog-only test update for MeshCentral's built-in Upgrade action.
|
||||
- Added a visible backend panel note so successful upgrade can be confirmed after MeshCentral installs the package.
|
||||
|
||||
## 0.0.4 - MeshCentral lifecycle alignment
|
||||
- Renamed the display/package name to GracePress Catalog Connector while preserving the stable `gracepress` slug and MeshCentral `shortName`.
|
||||
- Changed MeshCentral repository metadata to `git` so MeshCentral's built-in installer and upgrade action can download the GracePress-hosted zip.
|
||||
- Documented the active plugin identity rule: folder name, `shortName`, JS export, config row, and catalog slug must line up.
|
||||
- Prepared the live registry cleanup from duplicate `gracepress` rows into one active catalog-managed plugin.
|
||||
|
||||
## 0.0.3 - Catalog-managed backend panel
|
||||
- Moved the live GracePress MeshCentral dummy plugin into the GracePress workspace as an `mcplugin` package.
|
||||
- Added internal MeshCentral plugin header metadata for GracePress catalog builds.
|
||||
- Added backend admin panel catalog checks against the GracePress manifest endpoint.
|
||||
- Updated MeshCentral plugin config URLs to use the GracePress catalog-managed package, config, and changelog artifacts.
|
||||
|
||||
## 0.0.2 - Backend hello-world route
|
||||
- Replaced device-tab test behavior with a backend/admin plugin panel.
|
||||
- Kept MeshCentral plugin permissions unused while the current MeshCentral database adapter reports missing plugin-permission helpers.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "GracePress Catalog Connector",
|
||||
"shortName": "gracepress",
|
||||
"version": "0.0.18",
|
||||
"description": "MeshCentral backend connector for GracePress catalog-managed plugin delivery, updates, and diagnostics.",
|
||||
"author": "Jason Eugene Garrison",
|
||||
"homepage": "https://christit.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress"
|
||||
},
|
||||
"hasAdminPanel": true,
|
||||
"main": "gracepress.js",
|
||||
"configUrl": "https://christit.com/gracepress/gracepress.mcplugin.json",
|
||||
"downloadUrl": "https://christit.com/gracepress/gracepress.download.php",
|
||||
"changelogUrl": "https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress",
|
||||
"versionHistoryUrl": "https://christit.com/gracepress/gracepress.tags.json",
|
||||
"meshCentralCompat": ">=1.1.0"
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plugin Name: GracePress Catalog Connector
|
||||
* Description: MeshCentral backend connector for GracePress catalog-managed plugin delivery, updates, and diagnostics.
|
||||
* Version: 0.0.18
|
||||
* Stage: Alpha
|
||||
* Release Channel: Internal
|
||||
* Public Release: No
|
||||
* Public Candidate: No
|
||||
* Category: MeshCentral
|
||||
* Settings Slug: gracepress
|
||||
* WordPress.org Slug:
|
||||
* MeshCentral Short Name: gracepress
|
||||
* MeshCentral Config URL: https://christit.com/gracepress/gracepress.mcplugin.json
|
||||
* MeshCentral Compatibility: >=1.1.0
|
||||
* Requires PHP: 8.0
|
||||
* Icon: 🧩
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PLUGIN = {
|
||||
name: 'GracePress Catalog Connector',
|
||||
shortName: 'gracepress',
|
||||
version: '0.0.18',
|
||||
catalogUrl: 'https://christit.com/gracepress/catalog.json',
|
||||
manifestUrl: 'https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress',
|
||||
configUrl: 'https://christit.com/gracepress/gracepress.mcplugin.json',
|
||||
changelogUrl: 'https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress',
|
||||
};
|
||||
|
||||
const STATIC_MCPLUGIN_CONFIGS = [
|
||||
{
|
||||
name: 'GracePress Theme Studio',
|
||||
shortName: 'gpthemestudio',
|
||||
legacyShortNames: ['gracepress-theme-studio'],
|
||||
version: '0.0.3',
|
||||
description: 'MeshCentral UI theme profile editor for GracePress-managed MeshCentral servers.',
|
||||
author: 'Jason Eugene Garrison',
|
||||
homepage: 'https://christit.com',
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: 'https://christit.com/gracepress/api.php?endpoint=manifest&slug=gracepress-theme-studio',
|
||||
},
|
||||
hasAdminPanel: true,
|
||||
main: 'gpthemestudio.js',
|
||||
configUrl: 'https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json',
|
||||
downloadUrl: 'https://christit.com/gracepress/gracepress-theme-studio.download.php',
|
||||
changelogUrl: 'https://christit.com/gracepress/api.php?endpoint=changelog&slug=gracepress-theme-studio',
|
||||
versionHistoryUrl: 'https://christit.com/gracepress/gracepress-theme-studio.tags.json',
|
||||
meshCentralCompat: '>=1.1.0',
|
||||
},
|
||||
];
|
||||
|
||||
const syncState = {
|
||||
lastStarted: '',
|
||||
lastFinished: '',
|
||||
running: false,
|
||||
ok: false,
|
||||
added: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
module.exports.gracepress = function(parent) {
|
||||
const obj = { parent };
|
||||
obj.exports = [];
|
||||
|
||||
console.log(`${PLUGIN.name} loaded.`);
|
||||
console.log(`${PLUGIN.name} scheduling built-in MeshCentral plugin catalog sync.`);
|
||||
|
||||
syncMeshCentralCatalog(parent);
|
||||
scheduleCatalogSync(parent, 5000);
|
||||
|
||||
obj.server_startup = function() {
|
||||
syncMeshCentralCatalog(parent);
|
||||
};
|
||||
|
||||
obj.handleAdminReq = function(req, res, user) {
|
||||
if (!syncState.lastStarted && !syncState.running) {
|
||||
scheduleCatalogSync(parent, 0);
|
||||
}
|
||||
checkCatalog(function(catalog) {
|
||||
const username = (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin';
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderAdmin(username, catalog));
|
||||
});
|
||||
};
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
function scheduleCatalogSync(parent, delayMs) {
|
||||
setTimeout(function() {
|
||||
syncMeshCentralCatalog(parent);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function checkCatalog(done) {
|
||||
const url = `${PLUGIN.manifestUrl}&nocache=${Date.now()}`;
|
||||
https.get(url, { timeout: 4500 }, function(response) {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', function(chunk) {
|
||||
body += chunk;
|
||||
if (body.length > 262144) {
|
||||
response.destroy(new Error('Catalog response too large.'));
|
||||
}
|
||||
});
|
||||
response.on('end', function() {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
done({
|
||||
ok: response.statusCode >= 200 && response.statusCode < 300 && data && !data.error,
|
||||
statusCode: response.statusCode,
|
||||
manifest: data,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: data && data.error ? data.error : '',
|
||||
});
|
||||
} catch (err) {
|
||||
done({ ok: false, statusCode: response.statusCode, checkedAt: new Date().toISOString(), error: err.message });
|
||||
}
|
||||
});
|
||||
}).on('timeout', function() {
|
||||
this.destroy(new Error('Catalog request timed out.'));
|
||||
}).on('error', function(err) {
|
||||
done({ ok: false, statusCode: 0, checkedAt: new Date().toISOString(), error: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
function syncMeshCentralCatalog(parent) {
|
||||
if (syncState.running) return;
|
||||
|
||||
syncState.running = true;
|
||||
syncState.ok = false;
|
||||
syncState.lastStarted = new Date().toISOString();
|
||||
syncState.lastFinished = '';
|
||||
syncState.added = [];
|
||||
syncState.updated = [];
|
||||
syncState.skipped = [];
|
||||
syncState.errors = [];
|
||||
|
||||
runCatalogSync(parent).then(function(summary) {
|
||||
syncState.ok = true;
|
||||
syncState.added = summary.added;
|
||||
syncState.updated = summary.updated;
|
||||
syncState.skipped = summary.skipped;
|
||||
syncState.errors = summary.errors;
|
||||
console.log(`${PLUGIN.name} synced MeshCentral catalog: added ${summary.added.length}, updated ${summary.updated.length}, skipped ${summary.skipped.length}, errors ${summary.errors.length}.`);
|
||||
}).catch(function(err) {
|
||||
syncState.errors.push(err && err.message ? err.message : String(err));
|
||||
console.log(`${PLUGIN.name} catalog sync failed: ${syncState.errors.join('; ')}`);
|
||||
}).finally(function() {
|
||||
syncState.running = false;
|
||||
syncState.lastFinished = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
|
||||
async function runCatalogSync(parent) {
|
||||
const summary = { added: [], updated: [], skipped: [], errors: [] };
|
||||
const entries = STATIC_MCPLUGIN_CONFIGS.slice();
|
||||
console.log(`${PLUGIN.name} sync starting with ${entries.length} built-in MeshCentral plugin entries.`);
|
||||
const existing = await getExistingPlugins(parent);
|
||||
console.log(`${PLUGIN.name} sync found ${existing.length} MeshCentral plugin registry rows.`);
|
||||
const byShortName = new Map();
|
||||
const rowsByShortName = new Map();
|
||||
|
||||
existing.forEach(function(row) {
|
||||
const shortName = row && row.shortName ? row.shortName : '';
|
||||
if (!shortName) return;
|
||||
if (!byShortName.has(shortName)) byShortName.set(shortName, row);
|
||||
if (!rowsByShortName.has(shortName)) rowsByShortName.set(shortName, []);
|
||||
rowsByShortName.get(shortName).push(row);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const shortName = (entry && entry.shortName) || '';
|
||||
const configUrl = entry && entry.configUrl;
|
||||
|
||||
if (!shortName || !configUrl) {
|
||||
summary.skipped.push(`${entry && entry.name ? entry.name : 'unknown'}: missing MeshCentral config URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shortName === PLUGIN.shortName) {
|
||||
summary.skipped.push(`${shortName}: active connector package`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginRow = normalizePluginConfig(entry);
|
||||
if (!pluginRow || !pluginRow.shortName || !pluginRow.downloadUrl) {
|
||||
summary.errors.push(`${shortName}: invalid MeshCentral plugin config`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const legacyRows = [];
|
||||
if (Array.isArray(entry.legacyShortNames)) {
|
||||
for (const legacyShortName of entry.legacyShortNames) {
|
||||
if (rowsByShortName.has(legacyShortName)) legacyRows.push.apply(legacyRows, rowsByShortName.get(legacyShortName));
|
||||
}
|
||||
}
|
||||
|
||||
for (const legacyRow of legacyRows) {
|
||||
if (!isGracePressManagedRow(legacyRow)) continue;
|
||||
const legacyId = getPluginRowId(legacyRow);
|
||||
if (legacyId !== undefined && legacyId !== null) {
|
||||
await archivePluginRow(parent, legacyRow, 'legacy-shortname');
|
||||
await deletePluginRow(parent, legacyId, legacyRow.shortName);
|
||||
byShortName.delete(legacyRow.shortName);
|
||||
summary.updated.push(`${legacyRow.name || legacyRow.shortName} legacy archived`);
|
||||
} else if (legacyRow && legacyRow.shortName) {
|
||||
summary.errors.push(`${legacyRow.shortName}: legacy row id missing`);
|
||||
}
|
||||
}
|
||||
|
||||
const currentRows = rowsByShortName.get(pluginRow.shortName) || [];
|
||||
const current = chooseCurrentPluginRow(currentRows);
|
||||
for (const duplicateRow of currentRows) {
|
||||
if (duplicateRow === current || !isGracePressManagedRow(duplicateRow)) continue;
|
||||
const duplicateId = getPluginRowId(duplicateRow);
|
||||
if (duplicateId !== undefined && duplicateId !== null) {
|
||||
await archivePluginRow(parent, duplicateRow, 'duplicate-shortname');
|
||||
await deletePluginRow(parent, duplicateId, duplicateRow.shortName);
|
||||
summary.updated.push(`${duplicateRow.name || duplicateRow.shortName} duplicate archived`);
|
||||
} else {
|
||||
summary.errors.push(`${duplicateRow.shortName}: duplicate row id missing`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
console.log(`${PLUGIN.name} adding disabled MeshCentral plugin row for ${pluginRow.shortName}.`);
|
||||
await addPluginRow(parent, pluginRow);
|
||||
byShortName.set(pluginRow.shortName, pluginRow);
|
||||
summary.added.push(`${pluginRow.name} ${pluginRow.version}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const refreshed = Object.assign({}, pluginRow, { status: Number(current.status) || 0 });
|
||||
const currentId = getPluginRowId(current);
|
||||
if (currentId !== undefined && currentId !== null) refreshed._id = currentId;
|
||||
|
||||
if (pluginRowsMatch(current, refreshed)) {
|
||||
summary.skipped.push(`${pluginRow.shortName}: already available`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await updatePluginRow(parent, currentId, refreshed);
|
||||
byShortName.set(pluginRow.shortName, refreshed);
|
||||
summary.updated.push(`${pluginRow.name} ${pluginRow.version}`);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function getJson(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
https.get(url, { timeout: 6000 }, function(response) {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', function(chunk) {
|
||||
body += chunk;
|
||||
if (body.length > 1048576) {
|
||||
response.destroy(new Error('Response too large.'));
|
||||
}
|
||||
});
|
||||
response.on('end', function() {
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}).on('timeout', function() {
|
||||
this.destroy(new Error('Request timed out.'));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function getExistingPlugins(parent) {
|
||||
const db = parent && parent.parent && parent.parent.db;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!db || typeof db.getPlugins !== 'function') {
|
||||
reject(new Error('MeshCentral plugin database helper is unavailable.'));
|
||||
return;
|
||||
}
|
||||
db.getPlugins(function(err, docs) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve((Array.isArray(docs) ? docs : []).map(parsePluginDoc).filter(Boolean));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addPluginRow(parent, pluginRow) {
|
||||
const db = parent && parent.parent && parent.parent.db;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!db || typeof db.addPlugin !== 'function') {
|
||||
reject(new Error('MeshCentral addPlugin helper is unavailable.'));
|
||||
return;
|
||||
}
|
||||
db.addPlugin(pluginRow, function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updatePluginRow(parent, id, pluginRow) {
|
||||
const db = parent && parent.parent && parent.parent.db;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (id === undefined || id === null || !db || typeof db.updatePlugin !== 'function') {
|
||||
reject(new Error(`Cannot update ${pluginRow.shortName}; plugin id or update helper is unavailable.`));
|
||||
return;
|
||||
}
|
||||
db.updatePlugin(id, pluginRow, function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deletePluginRow(parent, id, shortName) {
|
||||
const db = parent && parent.parent && parent.parent.db;
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (id === undefined || id === null || !db || typeof db.deletePlugin !== 'function') {
|
||||
reject(new Error(`Cannot delete legacy ${shortName}; plugin id or delete helper is unavailable.`));
|
||||
return;
|
||||
}
|
||||
db.deletePlugin(id, function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function archivePluginRow(parent, pluginRow, reason) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
const pluginPath = parent && parent.pluginPath ? parent.pluginPath : '';
|
||||
if (!pluginPath) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldDir = path.join(pluginPath, '.old');
|
||||
fs.mkdirSync(oldDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const shortName = String(pluginRow.shortName || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const id = String(getPluginRowId(pluginRow) || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const file = path.join(oldDir, `gracepress-catalog-${reason}-${shortName}-${id}-${stamp}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify(pluginRow, null, 2) + '\n', 'utf8');
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parsePluginDoc(doc) {
|
||||
if (!doc) return null;
|
||||
const outerId = getPluginRowId(doc);
|
||||
const attachOuterId = function(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') return parsed;
|
||||
if (outerId !== undefined && outerId !== null && parsed._id === undefined && parsed.id === undefined) parsed._id = outerId;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
if (typeof doc === 'string') {
|
||||
try { return JSON.parse(doc); } catch (err) { return null; }
|
||||
}
|
||||
if (typeof doc.doc === 'string') {
|
||||
try { return attachOuterId(JSON.parse(doc.doc)); } catch (err) { return null; }
|
||||
}
|
||||
if (doc.doc && typeof doc.doc === 'object') return attachOuterId(doc.doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
function getPluginRowId(row) {
|
||||
if (!row || typeof row !== 'object') return undefined;
|
||||
if (row._id !== undefined && row._id !== null) return row._id;
|
||||
if (row.id !== undefined && row.id !== null) return row.id;
|
||||
if (row.pluginId !== undefined && row.pluginId !== null) return row.pluginId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function chooseCurrentPluginRow(rows) {
|
||||
if (!Array.isArray(rows) || !rows.length) return null;
|
||||
return rows.find(function(row) { return Number(row && row.status) === 1; }) || rows[0];
|
||||
}
|
||||
|
||||
function isGracePressManagedRow(row) {
|
||||
if (!row || typeof row !== 'object') return false;
|
||||
const values = [
|
||||
row.name,
|
||||
row.shortName,
|
||||
row.configUrl,
|
||||
row.downloadUrl,
|
||||
row.changelogUrl,
|
||||
row.versionHistoryUrl,
|
||||
row.homepage,
|
||||
row.repository && row.repository.url,
|
||||
].map(function(value) {
|
||||
return String(value || '').toLowerCase();
|
||||
});
|
||||
|
||||
return values.some(function(value) {
|
||||
return value.indexOf('gracepress') !== -1 || value.indexOf('christit.com/gracepress') !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePluginConfig(config) {
|
||||
if (!config || !config.shortName || !config.name) return null;
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
shortName: config.shortName,
|
||||
version: config.version || '0.0.0',
|
||||
description: config.description || '',
|
||||
hasAdminPanel: !!config.hasAdminPanel,
|
||||
homepage: config.homepage || 'https://christit.com',
|
||||
repository: {
|
||||
type: (config.repository && config.repository.type) || 'git',
|
||||
url: (config.repository && config.repository.url) || config.configUrl || '',
|
||||
},
|
||||
configUrl: config.configUrl || '',
|
||||
downloadUrl: config.downloadUrl || '',
|
||||
changelogUrl: config.changelogUrl || '',
|
||||
versionHistoryUrl: config.versionHistoryUrl || '',
|
||||
meshCentralCompat: config.meshCentralCompat || '>=1.1.0',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowsMatch(left, right) {
|
||||
const fields = [
|
||||
'name',
|
||||
'shortName',
|
||||
'version',
|
||||
'description',
|
||||
'hasAdminPanel',
|
||||
'homepage',
|
||||
'configUrl',
|
||||
'downloadUrl',
|
||||
'changelogUrl',
|
||||
'versionHistoryUrl',
|
||||
'meshCentralCompat',
|
||||
'status',
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
if (String(left[field] === undefined ? '' : left[field]) !== String(right[field] === undefined ? '' : right[field])) return false;
|
||||
}
|
||||
|
||||
const leftRepo = left.repository || {};
|
||||
const rightRepo = right.repository || {};
|
||||
return String(leftRepo.type || '') === String(rightRepo.type || '') && String(leftRepo.url || '') === String(rightRepo.url || '');
|
||||
}
|
||||
|
||||
function renderAdmin(username, catalog) {
|
||||
const manifest = catalog && catalog.manifest ? catalog.manifest : {};
|
||||
const catalogVersion = manifest.version || '';
|
||||
const updateAvailable = catalogVersion && compareVersions(catalogVersion, PLUGIN.version) > 0;
|
||||
const statusText = catalog && catalog.ok ? 'Catalog reachable' : 'Catalog unavailable';
|
||||
const statusClass = catalog && catalog.ok ? 'ok' : 'warn';
|
||||
const updateText = updateAvailable ? `Update available: v${escapeHtml(catalogVersion)}` : 'Installed version is current for this catalog response.';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(PLUGIN.name)}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 24px; color: #20242a; background: #f6f8fb; }
|
||||
.wrap { max-width: 920px; }
|
||||
.box { border: 1px solid #c8d0dc; border-radius: 6px; padding: 18px; background: #fff; margin-bottom: 14px; }
|
||||
h1 { margin: 0 0 10px; font-size: 22px; }
|
||||
h2 { margin: 0 0 10px; font-size: 16px; }
|
||||
p { line-height: 1.45; }
|
||||
code { background: #eef2f7; padding: 2px 5px; border-radius: 3px; }
|
||||
.grid { display: grid; grid-template-columns: 180px 1fr; gap: 8px 14px; }
|
||||
.label { color: #5e6a7d; font-weight: 700; }
|
||||
.ok { color: #087f5b; font-weight: 700; }
|
||||
.warn { color: #b45309; font-weight: 700; }
|
||||
a { color: #0f5e9c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="box">
|
||||
<h1>${escapeHtml(PLUGIN.name)}</h1>
|
||||
<p>This backend panel is served by the GracePress catalog-managed MeshCentral plugin package. Test update channel: 0.0.18.</p>
|
||||
<div class="grid">
|
||||
<div class="label">User</div><div>${escapeHtml(username)}</div>
|
||||
<div class="label">Short name</div><div><code>${escapeHtml(PLUGIN.shortName)}</code></div>
|
||||
<div class="label">Installed version</div><div><code>${escapeHtml(PLUGIN.version)}</code></div>
|
||||
<div class="label">Catalog status</div><div class="${statusClass}">${escapeHtml(statusText)}</div>
|
||||
<div class="label">Last check</div><div>${escapeHtml((catalog && catalog.checkedAt) || '')}</div>
|
||||
<div class="label">Update status</div><div>${escapeHtml(updateText)}</div>
|
||||
<div class="label">Plugin sync</div><div>${escapeHtml(renderSyncSummaryText())}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h2>GracePress Catalog Links</h2>
|
||||
<p><b>Manifest:</b> <a href="${escapeAttr(PLUGIN.manifestUrl)}" target="_blank" rel="noopener">${escapeHtml(PLUGIN.manifestUrl)}</a></p>
|
||||
<p><b>MeshCentral config:</b> <a href="${escapeAttr(PLUGIN.configUrl)}" target="_blank" rel="noopener">${escapeHtml(PLUGIN.configUrl)}</a></p>
|
||||
<p><b>Changelog:</b> <a href="${escapeAttr(PLUGIN.changelogUrl)}" target="_blank" rel="noopener">${escapeHtml(PLUGIN.changelogUrl)}</a></p>
|
||||
${catalog && catalog.error ? `<p class="warn"><b>Catalog error:</b> ${escapeHtml(catalog.error)}</p>` : ''}
|
||||
${renderSyncDetails()}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderSyncSummaryText() {
|
||||
if (syncState.running) return `Running since ${syncState.lastStarted}`;
|
||||
if (!syncState.lastFinished) return 'Waiting for MeshCentral startup sync.';
|
||||
if (syncState.ok) {
|
||||
return `Last sync ${syncState.lastFinished}: added ${syncState.added.length}, updated ${syncState.updated.length}, skipped ${syncState.skipped.length}, errors ${syncState.errors.length}.`;
|
||||
}
|
||||
return `Last sync failed ${syncState.lastFinished}: ${syncState.errors.join('; ')}`;
|
||||
}
|
||||
|
||||
function renderSyncDetails() {
|
||||
const lines = [];
|
||||
if (syncState.added.length) lines.push(`<p><b>Added as available:</b> ${escapeHtml(syncState.added.join(', '))}</p>`);
|
||||
if (syncState.updated.length) lines.push(`<p><b>Updated available rows:</b> ${escapeHtml(syncState.updated.join(', '))}</p>`);
|
||||
if (syncState.skipped.length) lines.push(`<p><b>Skipped:</b> ${escapeHtml(syncState.skipped.join(', '))}</p>`);
|
||||
if (syncState.errors.length) lines.push(`<p class="warn"><b>Sync errors:</b> ${escapeHtml(syncState.errors.join('; '))}</p>`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const left = String(a || '0').split('.').map(function(part) { return parseInt(part, 10) || 0; });
|
||||
const right = String(b || '0').split('.').map(function(part) { return parseInt(part, 10) || 0; });
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (left[i] || 0) - (right[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function(c) {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, '`');
|
||||
}
|
||||
Reference in New Issue
Block a user