Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
1367 lines
72 KiB
JavaScript
1367 lines
72 KiB
JavaScript
// pve-confsync web UI: clusters, differences per client, dry-run and synchronization
|
||
// (per object, per client, global), execution journal, audit trail and settings.
|
||
// No framework and no build step, like Logstream. The DOM is built with h():
|
||
// data coming from the clusters is always set as text, never as HTML.
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ---------- i18n ----------
|
||
const I18N = {
|
||
fr: {
|
||
demo: 'DÉMO', searchPh: 'Filtrer les objets (nom, champ, valeur)', refresh: 'Actualiser',
|
||
refreshTitle: 'Relire la référence et les clients', settings: 'Paramètres', theme: 'Thème clair / sombre',
|
||
logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewRuns: 'Journal', viewAudit: 'Audit',
|
||
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run global', syncAll: 'Tout synchroniser',
|
||
setupText: "Aucun cluster de référence n'est défini : ajoutez la référence puis les clients.", setupBtn: 'Ajouter les clusters',
|
||
stripAria: 'Clusters', colKind: 'Type', colObject: 'Objet', colStatus: 'Statut', colFields: 'Champs',
|
||
colDate: 'Date', colUser: 'Utilisateur', colMode: 'Mode', colScope: 'Portée', colClients: 'Clients', colSummary: 'Résumé',
|
||
colIP: 'Adresse', colAction: 'Action', colTarget: 'Cible', colResult: 'Résultat', colDetail: 'Détail',
|
||
colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)',
|
||
colRole: 'Rôle', colPassword: 'Nouveau mot de passe', colLastLogin: 'Dernière connexion',
|
||
more: 'Plus', toTop: 'Haut de page', close: 'Fermer', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer',
|
||
applyBtn: 'Synchroniser', addRow: '+ Ajouter',
|
||
kinds: { 'datacenter-options': 'Options datacenter', 'sdn-zone': 'Zones SDN', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' },
|
||
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' },
|
||
allKinds: 'Tous les types',
|
||
st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors référence', error: 'Erreur' },
|
||
filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs',
|
||
reference: 'RÉF', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run du client', syncClient: 'Synchroniser le client',
|
||
dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet',
|
||
refError: 'Référence illisible', clientError: 'Client illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||
fieldsN: '{n} champ(s)', noFields: 'aucun champ',
|
||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Référence', thMapping: 'Mapping client', thCurrent: 'Client actuel', thTarget: 'Après synchro',
|
||
emptyOk: 'Aucun écart pour ce client.', emptyOkHint: 'Le client est conforme à la référence pour les types synchronisés.',
|
||
emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.',
|
||
noClient: 'Aucun client.', noClientHint: 'Ajoutez des clusters clients dans les paramètres.',
|
||
connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Référence {ref} · {n} client(s) · {d} avec écarts · lu à {t}',
|
||
dryTitle: 'Dry-run : {what}', whatGlobal: 'tous les clients', whatClient: 'client {c}', whatObject: '{o} sur {c}',
|
||
reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur ce client.',
|
||
planSaved: 'Dry-run enregistré dans le journal (n° {id}).', changesN: '{n} changement(s)', sdnBadge: 'SDN',
|
||
sdnConfirm: "Les changements SDN sont appliqués sous verrou puis validés : le réseau de tous les nœuds de {c} est rechargé. Tapez « {w} » pour confirmer.",
|
||
sdnConfirmGlobal: "Des changements SDN rechargent le réseau des nœuds de plusieurs clients. Tapez le nom du cluster de référence « {w} » pour confirmer.",
|
||
applyNow: 'Appliquer', applying: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
||
relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était',
|
||
res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' },
|
||
mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'global', client: 'client', object: 'objet' },
|
||
allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs',
|
||
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
||
action: {
|
||
login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro',
|
||
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
|
||
'settings-save': 'paramètres', 'config-export': 'export YAML', 'config-import': 'import YAML',
|
||
'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe',
|
||
},
|
||
tabClusters: 'Clusters', tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabConfig: 'Import / export', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
|
||
addCluster: '+ Ajouter un cluster',
|
||
clustersHelp: "Un cluster de référence (token en lecture seule : Sys.Audit, SDN.Audit) et des clients (Sys.Audit, Sys.Modify si les options datacenter sont synchronisées, SDN.Audit, SDN.Allocate). Les secrets des tokens sont chiffrés en base et ne sont jamais réaffichés.",
|
||
test: 'Tester', edit: 'Modifier', testing: 'Test en cours…', testOk: 'Accès OK', role: { admin: 'admin', operator: 'opérateur', viewer: 'lecture seule' },
|
||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle', roleClient: 'Client', roleRef: 'Référence',
|
||
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||
tlsFp: 'Empreinte SHA-256 du certificat', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)',
|
||
secretKeep: 'Laisser vide pour conserver le secret enregistré.', secretNew: 'Secret du token API (UUID), chiffré en base.',
|
||
cMapping: 'Mapping (valeurs propres au client)', cMappingHelp: "Valeurs prioritaires sur la référence pour ce client : bridge, nodes, peers d'une zone, next-id… Les champs en politique « override » ne sont écrits qu'à partir d'ici.",
|
||
cPolicies: 'Politiques propres au client', cPoliciesHelp: 'Prioritaires sur les politiques globales pour ce client.',
|
||
confirmDelete: 'Supprimer le cluster {c} ? Ses mappings sont perdus.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
||
kindsTitle: 'Types synchronisés', kindsHelp: 'Hors périmètre v1 : EVPN, firewall, storage, pools/ACL, jobs, secrets.',
|
||
pruneTitle: 'Suppression', pruneLabel: 'Supprimer chez les clients les objets absents de la référence',
|
||
pruneHelp: 'Désactivé par défaut (mode additif + mise à jour). Activé, les dry-runs listent aussi les suppressions.',
|
||
selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?). Vide : tout.",
|
||
polTitle: 'Politiques par champ', polHelp: 'sync : copie de la référence ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping du client. Défauts :',
|
||
unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}',
|
||
usersHelp: "Comptes locaux (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
||
addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire',
|
||
exportTitle: 'Exporter', exportHelp: 'Configuration au format YAML de la CLI, versionnable dans Git. Les secrets ne sont pas exportés : chaque cluster reçoit une variable token_secret_env à renseigner.',
|
||
exportBtn: 'Télécharger confsync.yaml', importTitle: 'Importer',
|
||
importHelp: "Fichier YAML de la CLI : remplace les paramètres de synchronisation, crée ou met à jour les clusters. Les secrets sont lus depuis token_secret_env / token_secret_file s'ils sont accessibles au serveur, sinon le secret déjà enregistré est conservé.",
|
||
importPick: 'Choisir un fichier…', importReplace: 'Supprimer les clusters absents du fichier', importBtn: 'Importer', importOk: 'Configuration importée.',
|
||
accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation',
|
||
pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.',
|
||
accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC',
|
||
language: 'Langue', themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
|
||
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
|
||
noRights: 'Votre rôle ne permet pas de synchroniser.', errorPrefix: 'Erreur : ',
|
||
},
|
||
en: {
|
||
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
||
refreshTitle: 'Read the reference and the clients again', settings: 'Settings', theme: 'Light / dark theme',
|
||
logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewRuns: 'Journal', viewAudit: 'Audit',
|
||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Global dry-run', syncAll: 'Sync everything',
|
||
setupText: 'No reference cluster yet: add the reference, then the clients.', setupBtn: 'Add clusters',
|
||
stripAria: 'Clusters', colKind: 'Kind', colObject: 'Object', colStatus: 'Status', colFields: 'Fields',
|
||
colDate: 'Date', colUser: 'User', colMode: 'Mode', colScope: 'Scope', colClients: 'Clients', colSummary: 'Summary',
|
||
colIP: 'Address', colAction: 'Action', colTarget: 'Target', colResult: 'Result', colDetail: 'Detail',
|
||
colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)',
|
||
colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login',
|
||
more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete',
|
||
applyBtn: 'Synchronize', addRow: '+ Add',
|
||
kinds: { 'datacenter-options': 'Datacenter options', 'sdn-zone': 'SDN zones', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' },
|
||
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' },
|
||
allKinds: 'All kinds',
|
||
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not in ref.', error: 'Error' },
|
||
filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors',
|
||
reference: 'REF', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Client dry-run', syncClient: 'Synchronize client',
|
||
dryObj: 'Dry-run this object', syncObj: 'Synchronize this object',
|
||
refError: 'Reference unreadable', clientError: 'Client unreadable', planError: 'Blocking', warning: 'Warning',
|
||
fieldsN: '{n} field(s)', noFields: 'no field',
|
||
thField: 'Field', thPolicy: 'Policy', thRef: 'Reference', thMapping: 'Client mapping', thCurrent: 'Client now', thTarget: 'After sync',
|
||
emptyOk: 'No difference for this client.', emptyOkHint: 'The client is in line with the reference for the synchronized kinds.',
|
||
emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.',
|
||
noClient: 'No client.', noClientHint: 'Add client clusters in the settings.',
|
||
connOk: 'Connected', connKo: 'Offline', statsLine: 'Reference {ref} · {n} client(s) · {d} with differences · read at {t}',
|
||
dryTitle: 'Dry-run: {what}', whatGlobal: 'all clients', whatClient: 'client {c}', whatObject: '{o} on {c}',
|
||
reading: 'Reading the clusters…', planEmpty: 'Nothing to do: in sync.', planErrors: 'Blocking errors: nothing will be applied on this client.',
|
||
planSaved: 'Dry-run saved in the journal (#{id}).', changesN: '{n} change(s)', sdnBadge: 'SDN',
|
||
sdnConfirm: 'SDN changes are written under lock then committed: the network of every node of {c} is reloaded. Type "{w}" to confirm.',
|
||
sdnConfirmGlobal: 'SDN changes reload the network of the nodes of several clients. Type the name of the reference cluster "{w}" to confirm.',
|
||
applyNow: 'Apply', applying: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.',
|
||
relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was',
|
||
res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' },
|
||
mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'global', client: 'client', object: 'object' },
|
||
allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs',
|
||
noRuns: 'No run yet.', noAudit: 'No event.',
|
||
action: {
|
||
login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync',
|
||
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
|
||
'settings-save': 'settings', 'config-export': 'YAML export', 'config-import': 'YAML import',
|
||
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
|
||
},
|
||
tabClusters: 'Clusters', tabSync: 'Synchronization', tabUsers: 'Users', tabConfig: 'Import / export', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||
roNote: 'Read only: only administrators change these settings.',
|
||
addCluster: '+ Add a cluster',
|
||
clustersHelp: 'One reference cluster (read-only token: Sys.Audit, SDN.Audit) and clients (Sys.Audit, Sys.Modify when datacenter options are synchronized, SDN.Audit, SDN.Allocate). Token secrets are encrypted in the database and never shown again.',
|
||
test: 'Test', edit: 'Edit', testing: 'Testing…', testOk: 'Access OK', role: { admin: 'admin', operator: 'operator', viewer: 'read only' },
|
||
newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role', roleClient: 'Client', roleRef: 'Reference',
|
||
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout',
|
||
tlsFp: 'SHA-256 fingerprint of the certificate', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)',
|
||
secretKeep: 'Leave empty to keep the stored secret.', secretNew: 'API token secret (UUID), encrypted in the database.',
|
||
cMapping: 'Mapping (client specific values)', cMappingHelp: 'Values taking precedence over the reference for this client: bridge, nodes, peers of a zone, next-id… Fields with the "override" policy are only written from here.',
|
||
cPolicies: 'Client policies', cPoliciesHelp: 'Take precedence over the global policies for this client.',
|
||
confirmDelete: 'Delete cluster {c}? Its mappings are lost.', confirmDeleteUser: 'Delete user {u}?',
|
||
kindsTitle: 'Synchronized kinds', kindsHelp: 'Out of scope in v1: EVPN, firewall, storage, pools/ACL, jobs, secrets.',
|
||
pruneTitle: 'Deletion', pruneLabel: 'Delete on the clients the objects absent from the reference',
|
||
pruneHelp: 'Off by default (add + update). On, dry-runs also list the deletions.',
|
||
selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?). Empty: everything.',
|
||
polTitle: 'Field policies', polHelp: 'sync: copied from the reference; ignore: never read nor written; override: value from the client mapping. Defaults:',
|
||
unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}',
|
||
usersHelp: 'Local accounts (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.',
|
||
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
|
||
exportTitle: 'Export', exportHelp: 'Configuration in the YAML format of the CLI, versionable in Git. Secrets are not exported: each cluster gets a token_secret_env variable to fill.',
|
||
exportBtn: 'Download confsync.yaml', importTitle: 'Import',
|
||
importHelp: 'CLI YAML file: replaces the synchronization settings, creates or updates the clusters. Secrets are read from token_secret_env / token_secret_file when the server can reach them, otherwise the stored secret is kept.',
|
||
importPick: 'Choose a file…', importReplace: 'Delete the clusters absent from the file', importBtn: 'Import', importOk: 'Configuration imported.',
|
||
accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm',
|
||
pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.',
|
||
accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC',
|
||
language: 'Language', themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
|
||
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
|
||
noRights: 'Your role cannot synchronize.', errorPrefix: 'Error: ',
|
||
},
|
||
};
|
||
const KINDS = ['datacenter-options', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||
const POLICIES = ['sync', 'ignore', 'override'];
|
||
const ROLES = ['admin', 'operator', 'viewer'];
|
||
|
||
const store = {
|
||
get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } },
|
||
set(k, v) { try { if (v == null) localStorage.removeItem('confsync.' + k); else localStorage.setItem('confsync.' + k, v); } catch (e) { /* private mode */ } },
|
||
};
|
||
let lang = store.get('lang');
|
||
if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr';
|
||
|
||
function tr(path) {
|
||
let v = I18N[lang];
|
||
for (const p of path.split('.')) v = v == null ? v : v[p];
|
||
if (v == null) {
|
||
v = I18N.fr;
|
||
for (const p of path.split('.')) v = v == null ? v : v[p];
|
||
}
|
||
return v == null ? path : v;
|
||
}
|
||
function t(key, vars) {
|
||
let s = String(tr(key));
|
||
if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
|
||
return s;
|
||
}
|
||
|
||
// ---------- DOM helpers ----------
|
||
const $ = (s, r = document) => r.querySelector(s);
|
||
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
||
function h(tag, attrs, ...kids) {
|
||
const e = document.createElement(tag);
|
||
if (attrs) {
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') e.className = v;
|
||
else if (k === 'text') e.textContent = v;
|
||
else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
|
||
else if (k === 'dataset') Object.assign(e.dataset, v);
|
||
else if (v === true) e.setAttribute(k, '');
|
||
else e.setAttribute(k, v);
|
||
}
|
||
}
|
||
for (const k of kids.flat()) {
|
||
if (k == null || k === false) continue;
|
||
e.appendChild(typeof k === 'string' || typeof k === 'number' ? document.createTextNode(String(k)) : k);
|
||
}
|
||
return e;
|
||
}
|
||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||
const ICONS = {
|
||
scan: 'M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6M16 16l-1.9-1.9',
|
||
sync: 'M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5',
|
||
trash: 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6',
|
||
check: 'M5 12.5 10 17l9-10',
|
||
plug: 'M12 22v-5M9 8V2M15 8V2M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z',
|
||
pencil: 'M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5zM15 5l4 4',
|
||
x: 'M6 6l12 12M18 6 6 18',
|
||
};
|
||
function icon(name) {
|
||
const s = document.createElementNS(SVG_NS, 'svg');
|
||
s.setAttribute('viewBox', '0 0 24 24');
|
||
s.setAttribute('aria-hidden', 'true');
|
||
const p = document.createElementNS(SVG_NS, 'path');
|
||
p.setAttribute('d', ICONS[name]);
|
||
s.appendChild(p);
|
||
return s;
|
||
}
|
||
function iconBtn(name, title, onclick, cls) {
|
||
return h('button', { type: 'button', class: 'icon-btn' + (cls ? ' ' + cls : ''), title, 'aria-label': title, onclick }, icon(name));
|
||
}
|
||
function fmtTime(iso, withDate) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (isNaN(d)) return '';
|
||
const o = { hour: '2-digit', minute: '2-digit', second: '2-digit' };
|
||
if (withDate) Object.assign(o, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||
return d.toLocaleString(lang === 'fr' ? 'fr-FR' : 'en-GB', o);
|
||
}
|
||
function fmtVal(v) {
|
||
if (v == null) return '';
|
||
if (Array.isArray(v)) return '[' + v.join(', ') + ']';
|
||
return String(v);
|
||
}
|
||
|
||
let toastTimer;
|
||
function toast(msg, bad) {
|
||
const el = $('#toast');
|
||
el.textContent = msg;
|
||
el.className = 'toast' + (bad ? ' bad' : '');
|
||
el.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { el.hidden = true; }, bad ? 6000 : 3000);
|
||
}
|
||
let busyN = 0;
|
||
function busy(on) {
|
||
busyN += on ? 1 : -1;
|
||
document.body.classList.toggle('busy', busyN > 0);
|
||
}
|
||
|
||
// ---------- API ----------
|
||
async function api(method, path, body) {
|
||
const opt = { method, headers: { Accept: 'application/json' }, credentials: 'same-origin' };
|
||
if (method !== 'GET') opt.headers['X-Confsync'] = '1';
|
||
if (body !== undefined) {
|
||
opt.headers['Content-Type'] = 'application/json';
|
||
opt.body = JSON.stringify(body);
|
||
}
|
||
let r;
|
||
try {
|
||
r = await fetch(path, opt);
|
||
} catch (e) {
|
||
setConn(false);
|
||
throw e;
|
||
}
|
||
setConn(true);
|
||
if (r.status === 401) {
|
||
location.href = 'login.html?r=' + encodeURIComponent(location.pathname + location.search + location.hash);
|
||
throw new Error('auth');
|
||
}
|
||
const data = await r.json().catch(() => ({}));
|
||
if (!r.ok) {
|
||
const e = new Error(data.error || r.statusText);
|
||
e.status = r.status;
|
||
e.code = data.code;
|
||
e.data = data;
|
||
throw e;
|
||
}
|
||
return data;
|
||
}
|
||
function setConn(ok) {
|
||
const c = $('#conn');
|
||
c.className = 'conn ' + (ok ? 'ok' : 'ko');
|
||
c.textContent = t(ok ? 'connOk' : 'connKo');
|
||
}
|
||
|
||
// ---------- application state ----------
|
||
const S = {
|
||
me: null,
|
||
state: null, // last comparison
|
||
setup: false, // no reference yet
|
||
view: 'diff',
|
||
client: null, // selected client
|
||
open: new Set(), // expanded object rows (client|key)
|
||
kind: '',
|
||
status: store.get('status') || 'diff',
|
||
q: '',
|
||
runs: [], runsEnd: false, runOpen: new Set(), runMode: '',
|
||
events: [], eventsEnd: false, eventOpen: new Set(),
|
||
};
|
||
const can = {
|
||
sync: () => S.me && (S.me.role === 'admin' || S.me.role === 'operator'),
|
||
admin: () => S.me && S.me.role === 'admin',
|
||
};
|
||
|
||
// ---------- language, theme, density ----------
|
||
function applyLang() {
|
||
document.documentElement.lang = lang;
|
||
$$('[data-i18n]').forEach((e) => { e.textContent = t(e.dataset.i18n); });
|
||
$$('[data-i18n-title]').forEach((e) => { e.title = t(e.dataset.i18nTitle); });
|
||
$$('[data-i18n-aria]').forEach((e) => { e.setAttribute('aria-label', t(e.dataset.i18nAria)); });
|
||
$$('[data-i18n-ph]').forEach((e) => { e.placeholder = t(e.dataset.i18nPh); });
|
||
$$('#langSwitch [data-lang]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.lang === lang)));
|
||
fillSelects();
|
||
renderAll();
|
||
}
|
||
function fillSelects() {
|
||
const ks = $('#kindSel');
|
||
ks.replaceChildren(h('option', { value: '', text: t('allKinds') }), ...KINDS.map((k) => h('option', { value: k, text: t('kinds.' + k) })));
|
||
ks.value = S.kind;
|
||
ks.classList.toggle('set', !!S.kind);
|
||
const ss = $('#statusSel');
|
||
ss.replaceChildren(...[['diff', 'filterDiff'], ['all', 'filterAll'], ['same', 'filterSame'], ['error', 'filterError']]
|
||
.map(([v, k]) => h('option', { value: v, text: t(k) })));
|
||
ss.value = S.status;
|
||
ss.classList.toggle('set', S.status !== 'diff');
|
||
const ms = $('#runModeSel');
|
||
ms.replaceChildren(...[['', 'allModes'], ['apply', 'onlyApply'], ['dry-run', 'onlyDry']].map(([v, k]) => h('option', { value: v, text: t(k) })));
|
||
ms.value = S.runMode;
|
||
}
|
||
function themeOpt() { return store.get('theme') || 'system'; }
|
||
function setTheme(v) {
|
||
if (v === 'system') delete document.documentElement.dataset.theme;
|
||
else document.documentElement.dataset.theme = v;
|
||
store.set('theme', v === 'system' ? null : v);
|
||
$$('#themeSwitch [data-theme-opt]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.themeOpt === v)));
|
||
}
|
||
function setDensity(v) {
|
||
if (v === 'compact') document.documentElement.dataset.density = 'compact';
|
||
else delete document.documentElement.dataset.density;
|
||
store.set('density', v === 'compact' ? v : null);
|
||
$$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal'))));
|
||
}
|
||
|
||
// ---------- routing (#diff/<client>, #runs, #audit) ----------
|
||
function readHash() {
|
||
const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/');
|
||
S.view = ['diff', 'runs', 'audit'].includes(v) ? v : 'diff';
|
||
if (S.view === 'audit' && !can.admin()) S.view = 'diff';
|
||
if (c) S.client = c;
|
||
}
|
||
function writeHash() {
|
||
const hsh = '#' + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client) : '');
|
||
if (location.hash !== hsh) history.replaceState(null, '', hsh);
|
||
}
|
||
function setView(v) {
|
||
S.view = v;
|
||
writeHash();
|
||
renderView();
|
||
if (v === 'runs') loadRuns(true);
|
||
if (v === 'audit') loadAudit(true);
|
||
}
|
||
|
||
// ---------- loading ----------
|
||
async function loadState(refresh) {
|
||
busy(true);
|
||
$('#refreshBtn').classList.add('spin');
|
||
try {
|
||
const d = await api('GET', 'api/state' + (refresh ? '?refresh=1' : ''));
|
||
S.setup = !!d.setup;
|
||
S.state = d.state || null;
|
||
$('#error').hidden = true;
|
||
if (S.state) {
|
||
const names = S.state.clients.map((c) => c.name);
|
||
if (!names.includes(S.client)) {
|
||
// first client with something to do, else the first one
|
||
const first = S.state.clients.find((c) => c.error || (c.plan && (c.plan.changes || []).length) || (c.plan && (c.plan.errors || []).length));
|
||
S.client = first ? first.name : names[0] || null;
|
||
}
|
||
}
|
||
writeHash();
|
||
} catch (e) {
|
||
if (e.message !== 'auth') showError(e.message);
|
||
} finally {
|
||
busy(false);
|
||
$('#refreshBtn').classList.remove('spin');
|
||
}
|
||
renderAll();
|
||
}
|
||
function showError(msg) {
|
||
const el = $('#error');
|
||
el.textContent = msg;
|
||
el.hidden = !msg;
|
||
}
|
||
|
||
// ---------- rendering: differences ----------
|
||
function renderAll() {
|
||
renderView();
|
||
renderStatus();
|
||
}
|
||
function renderView() {
|
||
$$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view)));
|
||
$('#diffView').hidden = S.view !== 'diff';
|
||
$('#runsView').hidden = S.view !== 'runs';
|
||
$('#auditView').hidden = S.view !== 'audit';
|
||
$$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; });
|
||
$$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; });
|
||
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
|
||
$('#setup').hidden = !S.setup;
|
||
if (S.view === 'diff') renderDiff();
|
||
if (S.view === 'runs') renderRuns();
|
||
if (S.view === 'audit') renderAudit();
|
||
}
|
||
|
||
function clientStatus(c) {
|
||
if (c.error || (S.state && S.state.reference.error)) return 'error';
|
||
const p = c.plan || {};
|
||
if ((p.errors || []).length) return 'error';
|
||
if ((p.changes || []).length) return 'diff';
|
||
return 'ok';
|
||
}
|
||
|
||
function renderDiff() {
|
||
const strip = $('#strip');
|
||
strip.replaceChildren();
|
||
const st = S.state;
|
||
if (!st) {
|
||
$('#clientBar').hidden = true;
|
||
$('#objTable').hidden = true;
|
||
$('#objEmpty').hidden = true;
|
||
$('#notes').replaceChildren();
|
||
$('#count').textContent = '';
|
||
return;
|
||
}
|
||
// reference card
|
||
const r = st.reference;
|
||
strip.appendChild(h('div', { class: 'ccard ref' + (r.error ? ' st-error' : '') },
|
||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }), h('span', { class: 'c-name', text: r.name }),
|
||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||
h('div', { class: 'c-url', text: r.url }),
|
||
r.error ? h('div', { class: 'c-err', text: r.error, title: r.error })
|
||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('fieldsN', { n: r.objects }).replace(/champ\(s\)|field\(s\)/, lang === 'fr' ? 'objet(s)' : 'object(s)') }),
|
||
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
|
||
for (const c of st.clients) {
|
||
const cs = clientStatus(c);
|
||
const counts = h('div', { class: 'c-counts' });
|
||
if (c.error) {
|
||
counts.appendChild(h('span', { class: 'cnt error', text: t('unreadable') }));
|
||
} else if (!r.error) {
|
||
const n = c.counts || {};
|
||
let any = false;
|
||
for (const [k, sym] of [['create', '+'], ['update', '~'], ['delete', '−'], ['error', '!'], ['extra', '?']]) {
|
||
if (n[k]) { any = true; counts.appendChild(h('span', { class: 'cnt ' + k, title: t('st.' + k), text: sym + n[k] })); }
|
||
}
|
||
if (!any) counts.appendChild(h('span', { class: 'cnt ok', text: '✓ ' + t('inLine') }));
|
||
}
|
||
strip.appendChild(h('button', {
|
||
type: 'button', role: 'tab', class: 'ccard st-' + cs, 'aria-selected': String(c.name === S.client),
|
||
onclick: () => { S.client = c.name; S.open.clear(); writeHash(); renderDiff(); },
|
||
},
|
||
h('div', { class: 'c-head' }, h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||
h('div', { class: 'c-url', text: c.url }),
|
||
counts,
|
||
c.error ? h('div', { class: 'c-err', text: c.error, title: c.error }) : null));
|
||
}
|
||
renderClient();
|
||
}
|
||
|
||
function selected() {
|
||
return S.state && S.state.clients.find((c) => c.name === S.client);
|
||
}
|
||
|
||
function renderClient() {
|
||
const c = selected();
|
||
const bar = $('#clientBar');
|
||
const notes = $('#notes');
|
||
notes.replaceChildren();
|
||
const st = S.state;
|
||
if (st.reference.error) notes.appendChild(note('err', t('refError'), st.reference.error));
|
||
for (const w of st.reference.warnings || []) notes.appendChild(note('info', t('warning'), w));
|
||
if (!c) {
|
||
bar.hidden = true;
|
||
$('#objTable').hidden = true;
|
||
emptyState(t('noClient'), t('noClientHint'));
|
||
$('#count').textContent = '';
|
||
return;
|
||
}
|
||
const p = c.plan || { changes: [], errors: [], warnings: [] };
|
||
const nChanges = (p.changes || []).length;
|
||
const blocked = (p.errors || []).length > 0 || !!c.error || !!st.reference.error;
|
||
const meta = [];
|
||
if (c.version) meta.push('PVE ' + c.version);
|
||
if (c.nodes && c.nodes.length) meta.push(t('nodes', { n: c.nodes.length }));
|
||
if (c.readAt) meta.push(t('readAt', { t: fmtTime(c.readAt), ms: c.ms }));
|
||
bar.hidden = false;
|
||
bar.replaceChildren(
|
||
h('h2', { text: c.name }),
|
||
h('span', { class: 'meta', text: meta.join(' · ') }),
|
||
can.sync() ? h('div', { class: 'acts' },
|
||
h('button', { type: 'button', class: 'btn tool', disabled: !!c.error || !!st.reference.error, onclick: () => openPlan({ scope: 'client', client: c.name }) },
|
||
icon('scan'), t('dryClient')),
|
||
h('button', { type: 'button', class: 'btn tool sync', disabled: blocked || nChanges === 0, onclick: () => openPlan({ scope: 'client', client: c.name }, true) },
|
||
icon('sync'), t('syncClient'))) : null);
|
||
if (c.error) notes.appendChild(note('err', t('clientError'), c.error));
|
||
for (const e of p.errors || []) notes.appendChild(note('err', t('planError'), e));
|
||
for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), w));
|
||
|
||
const rows = (c.rows || []).filter(match);
|
||
$('#count').textContent = (c.rows || []).length ? rows.length + ' / ' + c.rows.length : '';
|
||
const list = $('#objList');
|
||
list.replaceChildren();
|
||
if (!rows.length) {
|
||
$('#objTable').hidden = true;
|
||
if (c.error) { $('#objEmpty').hidden = true; return; }
|
||
const filtered = (c.rows || []).length && (S.kind || S.q || S.status !== 'diff');
|
||
if (filtered) emptyState(t('emptyFilter'), t('emptyFilterHint'));
|
||
else emptyState(t('emptyOk'), t('emptyOkHint'));
|
||
return;
|
||
}
|
||
$('#objEmpty').hidden = true;
|
||
$('#objTable').hidden = false;
|
||
for (const r of rows) list.appendChild(objRow(c, r));
|
||
}
|
||
function emptyState(title, hint) {
|
||
$('#objEmpty').hidden = false;
|
||
$('#objEmptyTitle').textContent = title;
|
||
$('#objEmptyHint').textContent = hint;
|
||
}
|
||
function note(kind, label, text) {
|
||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text }));
|
||
}
|
||
|
||
function match(r) {
|
||
if (S.kind && r.kind !== S.kind) return false;
|
||
switch (S.status) {
|
||
case 'diff': if (r.status === 'same' || r.status === 'extra') return false; break;
|
||
case 'same': if (r.status !== 'same') return false; break;
|
||
case 'error': if (r.status !== 'error') return false; break;
|
||
}
|
||
if (S.q) {
|
||
const q = S.q.toLowerCase();
|
||
const hay = [r.id, r.kind, ...(r.fields || []).flatMap((f) => [f.name, fmtVal(f.ref), fmtVal(f.current), fmtVal(f.mapping)])].join('\n').toLowerCase();
|
||
if (!hay.includes(q)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function summary(r) {
|
||
const s = h('span', { class: 'sum' });
|
||
if (r.status === 'error') { s.textContent = (r.errors || [])[0] || ''; s.title = s.textContent; return s; }
|
||
const ch = (r.fields || []).filter((f) => f.change);
|
||
if (!ch.length || r.status === 'delete') {
|
||
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
||
return s;
|
||
}
|
||
ch.forEach((f, i) => {
|
||
if (i) s.appendChild(document.createTextNode(' '));
|
||
const sym = f.change === 'add' ? '+' : f.change === 'set' ? '~' : '−';
|
||
s.appendChild(h('span', { class: 'f-' + f.change, text: sym + f.name }));
|
||
});
|
||
s.title = ch.map((f) => f.name).join(', ');
|
||
return s;
|
||
}
|
||
|
||
function objRow(c, r) {
|
||
const key = c.name + '|' + r.key;
|
||
const open = S.open.has(key);
|
||
const actionable = ['create', 'update', 'delete'].includes(r.status);
|
||
const row = h('div', {
|
||
class: 'row st-' + r.status + (open ? ' open' : ''), tabindex: '0', role: 'button', 'aria-expanded': String(open),
|
||
onclick: (ev) => { if (ev.target.closest('.acts') || ev.target.closest('.details')) return; toggle(); },
|
||
onkeydown: (ev) => { if ((ev.key === 'Enter' || ev.key === ' ') && ev.target === row) { ev.preventDefault(); toggle(); } },
|
||
},
|
||
h('span', { class: 'kind ' + r.kind, text: t('kindShort.' + r.kind), title: t('kinds.' + r.kind) }),
|
||
h('span', { class: 'id', text: r.id, title: r.id }),
|
||
h('span', null, h('span', { class: 'badge ' + r.status, text: t('st.' + r.status) })),
|
||
summary(r),
|
||
h('span', { class: 'acts' }, can.sync() && actionable && !c.error ? [
|
||
iconBtn('scan', t('dryObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] })),
|
||
iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go'),
|
||
] : null));
|
||
function toggle() {
|
||
if (S.open.has(key)) S.open.delete(key); else S.open.add(key);
|
||
renderClient();
|
||
}
|
||
if (open) row.appendChild(details(r));
|
||
return row;
|
||
}
|
||
|
||
function details(r) {
|
||
const d = h('div', { class: 'details' });
|
||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.join('\n') }));
|
||
const tb = h('tbody');
|
||
for (const f of r.fields || []) {
|
||
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
||
tb.appendChild(h('tr', { class: (f.change ? 'ch-' + f.change : '') + ' pol-' + f.policy },
|
||
h('td', { class: 'name', text: f.name }),
|
||
h('td', null, h('span', { class: 'pol ' + f.policy, text: f.policy })),
|
||
cell(f.ref, 'ref'), cell(f.mapping, 'map'), cell(f.current, 'cur'),
|
||
cell(f.change === 'unset' ? null : f.target, 'target')));
|
||
}
|
||
if (!(r.fields || []).length) {
|
||
d.appendChild(h('p', { class: 'muted', text: t('noFields') }));
|
||
return d;
|
||
}
|
||
d.appendChild(h('table', { class: 'fields' },
|
||
h('thead', null, h('tr', null, ...['thField', 'thPolicy', 'thRef', 'thMapping', 'thCurrent', 'thTarget'].map((k) => h('th', { text: t(k) })))),
|
||
tb));
|
||
return d;
|
||
}
|
||
|
||
function renderStatus() {
|
||
const st = S.state;
|
||
const s = $('#stats');
|
||
if (!st) { s.textContent = ''; return; }
|
||
const d = st.clients.filter((c) => clientStatus(c) !== 'ok').length;
|
||
s.textContent = t('statsLine', { ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) });
|
||
}
|
||
|
||
// ---------- dry-run, confirmation, synchronization ----------
|
||
const plan = { sel: null, plans: null, run: 0, poll: 0 };
|
||
|
||
function whatLabel(sel) {
|
||
if (sel.scope === 'global') return t('whatGlobal');
|
||
if (sel.scope === 'client') return t('whatClient', { c: sel.client });
|
||
return t('whatObject', { o: sel.objects.join(', '), c: sel.client });
|
||
}
|
||
|
||
async function openPlan(sel, intentSync) {
|
||
plan.sel = sel;
|
||
plan.plans = null;
|
||
clearInterval(plan.poll);
|
||
const dlg = $('#planDlg');
|
||
$('#planTitle').textContent = t('dryTitle', { what: whatLabel(sel) });
|
||
$('#planBody').replaceChildren(h('p', { class: 'muted', text: t('reading') }));
|
||
$('#planNote').textContent = '';
|
||
$('#planApply').hidden = true;
|
||
if (!dlg.open) dlg.showModal();
|
||
busy(true);
|
||
try {
|
||
const d = await api('POST', 'api/dryrun', sel);
|
||
plan.plans = d.plans;
|
||
renderPlans(d.plans);
|
||
$('#planNote').textContent = t('planSaved', { id: d.run });
|
||
const total = d.plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).length, 0);
|
||
const blocked = d.plans.some((p) => p.error || ((p.plan && p.plan.errors) || []).length);
|
||
const ap = $('#planApply');
|
||
ap.hidden = !can.sync() || total === 0 || blocked;
|
||
ap.disabled = false;
|
||
if (!ap.hidden && intentSync) ap.focus();
|
||
// the dry-run read the clusters again: show what it read
|
||
loadState(false);
|
||
} catch (e) {
|
||
if (e.message === 'auth') return;
|
||
$('#planBody').replaceChildren(h('div', { class: 'error-banner', text: e.message }));
|
||
} finally {
|
||
busy(false);
|
||
}
|
||
}
|
||
|
||
function changeList(changes) {
|
||
const ul = h('ul', { class: 'plan' });
|
||
for (const ch of changes || []) {
|
||
const li = h('li', null, h('div', { class: 'ph' },
|
||
h('span', { class: 'badge ' + ch.op, text: t('st.' + ch.op) }),
|
||
h('span', { class: 'kind ' + ch.kind, text: t('kindShort.' + ch.kind) }),
|
||
h('strong', { text: ch.id })));
|
||
const pf = h('ul', { class: 'pf' });
|
||
const set = ch.set || {};
|
||
const old = ch.old || {};
|
||
for (const k of Object.keys(set).sort()) {
|
||
if (k in old) pf.appendChild(h('li', { class: 'set' }, '~ ' + k + ' : ', h('del', { text: fmtVal(old[k]) }), ' → ', h('ins', { text: fmtVal(set[k]) })));
|
||
else pf.appendChild(h('li', { class: 'add' }, '+ ' + k + ' = ', h('ins', { text: fmtVal(set[k]) })));
|
||
}
|
||
for (const k of ch.unset || []) pf.appendChild(h('li', { class: 'unset', text: '− ' + k + ' (' + t('was') + ' ' + fmtVal(old[k]) + ')' }));
|
||
if (ch.op === 'delete') pf.appendChild(h('li', { class: 'unset', text: Object.keys(old).sort().map((k) => k + '=' + fmtVal(old[k])).join(', ') }));
|
||
if (pf.children.length) li.appendChild(pf);
|
||
ul.appendChild(li);
|
||
}
|
||
return ul;
|
||
}
|
||
|
||
function renderPlans(plans) {
|
||
const body = $('#planBody');
|
||
body.replaceChildren();
|
||
for (const p of plans) {
|
||
const changes = (p.plan && p.plan.changes) || [];
|
||
const errs = (p.plan && p.plan.errors) || [];
|
||
body.appendChild(h('div', { class: 'run-client' },
|
||
h('h4', null, p.client,
|
||
h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'),
|
||
text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }),
|
||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null),
|
||
p.error ? note('err', t('clientError'), p.error) : null,
|
||
errs.length ? h('div', { class: 'notes' }, note('err', t('planError'), t('planErrors')), ...errs.map((e) => note('err', '', e))) : null,
|
||
((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), w))) : null,
|
||
changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null)));
|
||
}
|
||
}
|
||
|
||
async function applyPlan() {
|
||
const sel = plan.sel;
|
||
const plans = plan.plans || [];
|
||
const sdn = plans.some((p) => p.sdn && ((p.plan && p.plan.changes) || []).length);
|
||
const want = sel.scope === 'global' ? S.state.reference.name : sel.client;
|
||
let confirmText = '';
|
||
if (sdn) {
|
||
let box = $('#planBody .confirm-box');
|
||
if (!box) {
|
||
const inp = h('input', { class: 'inp mono', autocomplete: 'off', spellcheck: 'false', 'aria-label': want });
|
||
box = h('div', { class: 'confirm-box' },
|
||
h('span', { text: sel.scope === 'global' ? t('sdnConfirmGlobal', { w: want }) : t('sdnConfirm', { c: sel.client, w: want }) }), inp);
|
||
$('#planBody').appendChild(box);
|
||
$('#planApply').disabled = true;
|
||
inp.addEventListener('input', () => { $('#planApply').disabled = inp.value !== want; });
|
||
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter' && inp.value === want) applyPlan(); });
|
||
inp.focus();
|
||
box.scrollIntoView({ block: 'nearest' });
|
||
return;
|
||
}
|
||
confirmText = $('input', box).value;
|
||
if (confirmText !== want) return;
|
||
}
|
||
const digests = {};
|
||
for (const p of plans) digests[p.client] = p.digest;
|
||
$('#planApply').disabled = true;
|
||
busy(true);
|
||
try {
|
||
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||
if (!d.run) { toast(d.summary || t('planEmpty')); return; }
|
||
plan.run = d.run;
|
||
$('#planApply').hidden = true;
|
||
$('#planTitle').textContent = t('applying');
|
||
$('#planNote').textContent = t('runN', { id: d.run });
|
||
pollRun();
|
||
plan.poll = setInterval(pollRun, 1000);
|
||
} catch (e) {
|
||
if (e.message === 'auth') return;
|
||
$('#planApply').disabled = false;
|
||
if (e.code === 'stale') {
|
||
$('#planBody').prepend(h('div', { class: 'notes' }, note('warn', t('warning'), t('stale')),
|
||
h('div', null, h('button', { type: 'button', class: 'btn tool', onclick: () => openPlan(sel), text: t('relaunch') }))));
|
||
$('#planApply').hidden = true;
|
||
} else {
|
||
toast(t('errorPrefix') + e.message, true);
|
||
}
|
||
} finally {
|
||
busy(false);
|
||
}
|
||
}
|
||
|
||
async function pollRun() {
|
||
let run;
|
||
try {
|
||
run = await api('GET', 'api/runs/' + plan.run);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
$('#planBody').replaceChildren(runDetail(run));
|
||
if (run.status !== 'running') {
|
||
clearInterval(plan.poll);
|
||
$('#planTitle').textContent = t('applyDone') + ' — ' + t('res.' + run.status);
|
||
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + run.summary;
|
||
loadState(true);
|
||
if (S.view === 'runs') loadRuns(true);
|
||
}
|
||
}
|
||
|
||
function runDetail(run) {
|
||
const d = run.detail || {};
|
||
const wrap = h('div');
|
||
const results = d.results || {};
|
||
for (const p of d.plans || []) {
|
||
const res = results[p.client];
|
||
const changes = (p.plan && p.plan.changes) || [];
|
||
const errs = (p.plan && p.plan.errors) || [];
|
||
const box = h('div', { class: 'run-client' },
|
||
h('h4', null, p.client,
|
||
res ? h('span', { class: 'badge ' + res.status, text: t('res.' + res.status) })
|
||
: h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'),
|
||
text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }),
|
||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null));
|
||
if (p.error) box.appendChild(note('err', t('clientError'), p.error));
|
||
for (const e of errs) box.appendChild(note('err', t('planError'), e));
|
||
if (changes.length) box.appendChild(changeList(changes));
|
||
if (res && res.steps && res.steps.length) {
|
||
box.appendChild(h('ul', { class: 'steps' }, ...res.steps.map((s) => h('li', null,
|
||
h('time', { text: fmtTime(s.at) }), h('span', { class: s.error ? 'ko' : 'ok', text: s.error ? '✗' : '✓' }),
|
||
h('span', { text: s.action }), s.error ? h('span', { class: 'e', text: s.error }) : null))));
|
||
}
|
||
if (res && res.error) box.appendChild(note('err', t('st.error'), res.error));
|
||
wrap.appendChild(box);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
// ---------- journal ----------
|
||
async function loadRuns(reset) {
|
||
if (reset) { S.runs = []; S.runsEnd = false; }
|
||
const before = S.runs.length ? S.runs[S.runs.length - 1].id : 0;
|
||
busy(true);
|
||
try {
|
||
const d = await api('GET', 'api/runs?limit=100' + (before ? '&before=' + before : ''));
|
||
S.runs = S.runs.concat(d.runs);
|
||
S.runsEnd = d.runs.length < 100;
|
||
} catch (e) {
|
||
if (e.message !== 'auth') showError(e.message);
|
||
} finally {
|
||
busy(false);
|
||
}
|
||
renderRuns();
|
||
}
|
||
function renderRuns() {
|
||
const list = $('#runList');
|
||
list.replaceChildren();
|
||
const runs = S.runs.filter((r) => !S.runMode || r.mode === S.runMode)
|
||
.filter((r) => !S.q || [r.user, r.summary, ...(r.clients || []), ...(r.objects || [])].join(' ').toLowerCase().includes(S.q.toLowerCase()));
|
||
$('#count').textContent = S.runs.length ? String(runs.length) : '';
|
||
if (!runs.length) list.appendChild(h('div', { class: 'row', role: 'presentation' }, h('span', { class: 'muted txt', text: t('noRuns') })));
|
||
for (const r of runs) {
|
||
const open = S.runOpen.has(r.id);
|
||
const row = h('div', { class: 'row' + (open ? ' open' : '') + (r.status === 'error' ? ' st-error' : r.mode === 'apply' ? ' st-update' : ''), tabindex: '0', role: 'button',
|
||
onclick: (ev) => { if (ev.target.closest('.details')) return; toggleRun(r.id); },
|
||
onkeydown: (ev) => { if (ev.key === 'Enter' && ev.target === row) toggleRun(r.id); } },
|
||
h('time', { text: fmtTime(r.startedAt, true) }),
|
||
h('span', { class: 'user', text: r.user }),
|
||
h('span', null, h('span', { class: 'badge ' + r.mode, text: t('mode.' + r.mode) })),
|
||
h('span', { class: 'txt', text: t('scope.' + r.scope) }),
|
||
h('span', { class: 'txt mono', text: (r.clients || []).join(', '), title: (r.objects || []).join(', ') }),
|
||
h('span', null, h('span', { class: 'badge ' + r.status, text: t('res.' + r.status) })),
|
||
h('span', { class: 'txt', text: r.summary, title: r.summary }));
|
||
if (open) {
|
||
const det = h('div', { class: 'details' }, h('p', { class: 'muted', text: '…' }));
|
||
row.appendChild(det);
|
||
api('GET', 'api/runs/' + r.id).then((full) => {
|
||
det.replaceChildren(runDetail(full));
|
||
if ((full.objects || []).length) det.prepend(h('p', { class: 'muted small mono', text: full.objects.join(', ') }));
|
||
}).catch((e) => { det.replaceChildren(h('p', { class: 'errs', text: e.message })); });
|
||
}
|
||
list.appendChild(row);
|
||
}
|
||
$('#runMore').hidden = S.runsEnd;
|
||
}
|
||
function toggleRun(id) {
|
||
if (S.runOpen.has(id)) S.runOpen.delete(id); else S.runOpen.add(id);
|
||
renderRuns();
|
||
}
|
||
|
||
// ---------- audit ----------
|
||
async function loadAudit(reset) {
|
||
if (reset) { S.events = []; S.eventsEnd = false; }
|
||
const before = S.events.length ? S.events[S.events.length - 1].id : 0;
|
||
busy(true);
|
||
try {
|
||
const d = await api('GET', 'api/audit?limit=200' + (before ? '&before=' + before : ''));
|
||
S.events = S.events.concat(d.events);
|
||
S.eventsEnd = d.events.length < 200;
|
||
} catch (e) {
|
||
if (e.message !== 'auth') showError(e.message);
|
||
} finally {
|
||
busy(false);
|
||
}
|
||
renderAudit();
|
||
}
|
||
function renderAudit() {
|
||
const list = $('#auditList');
|
||
list.replaceChildren();
|
||
const q = S.q.toLowerCase();
|
||
const evs = S.events.filter((e) => !q || [e.user, e.ip, e.action, e.target, e.detail].join(' ').toLowerCase().includes(q));
|
||
$('#count').textContent = S.events.length ? String(evs.length) : '';
|
||
if (!evs.length) list.appendChild(h('div', { class: 'row', role: 'presentation' }, h('span', { class: 'muted txt', text: t('noAudit') })));
|
||
for (const e of evs) {
|
||
const open = S.eventOpen.has(e.id);
|
||
const row = h('div', { class: 'row' + (e.ok ? '' : ' st-error') + (open ? ' open' : ''), tabindex: '0', role: 'button',
|
||
onclick: () => { if (open) S.eventOpen.delete(e.id); else S.eventOpen.add(e.id); renderAudit(); } },
|
||
h('time', { text: fmtTime(e.at, true) }),
|
||
h('span', { class: 'user', text: e.user || '—', title: e.role ? t('role.' + e.role) : '' }),
|
||
h('span', { class: 'txt mono', text: e.ip }),
|
||
h('span', { class: 'txt', text: tr('action.' + e.action) === 'action.' + e.action ? e.action : t('action.' + e.action) }),
|
||
h('span', { class: 'txt mono', text: e.target, title: e.target }),
|
||
h('span', null, h('span', { class: 'badge ' + (e.ok ? 'ok' : 'ko'), text: e.ok ? 'OK' : '✗' })),
|
||
h('span', { class: 'txt', text: e.detail, title: e.detail }));
|
||
if (open && e.detail) row.appendChild(h('div', { class: 'details mono', text: e.detail }));
|
||
list.appendChild(row);
|
||
}
|
||
$('#auditMore').hidden = S.eventsEnd;
|
||
}
|
||
|
||
// ---------- settings ----------
|
||
const set = { tab: 'clusters', clusters: [], settings: null, defaults: {}, dirty: false };
|
||
|
||
function openSettings(tab) {
|
||
const dlg = $('#settingsDlg');
|
||
const admin = can.admin();
|
||
$$('#setNav [data-admin]').forEach((b) => { b.hidden = !admin; });
|
||
$$('[data-ro]').forEach((e) => { e.hidden = admin; });
|
||
$$('[data-admin-only]').forEach((e) => { e.hidden = !admin; });
|
||
$$('#settingsDlg [data-edit]').forEach((e) => { e.inert = !admin; });
|
||
dlg.classList.toggle('read-only', !admin);
|
||
if (!dlg.open) dlg.showModal();
|
||
showTab(tab || set.tab);
|
||
}
|
||
function showTab(tab) {
|
||
if ((tab === 'users' || tab === 'config') && !can.admin()) tab = 'clusters';
|
||
set.tab = tab;
|
||
$$('#setNav [data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === tab)));
|
||
$$('#settingsDlg [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
|
||
$('#setActions').hidden = tab !== 'sync' || !can.admin();
|
||
if (tab === 'clusters') loadClusters();
|
||
if (tab === 'sync') loadSettings();
|
||
if (tab === 'users') loadUsers();
|
||
if (tab === 'account') renderAccount();
|
||
if (tab === 'appearance') {
|
||
setTheme(themeOpt());
|
||
setDensity(store.get('density') || 'normal');
|
||
applyLang();
|
||
}
|
||
}
|
||
|
||
// clusters
|
||
async function loadClusters() {
|
||
try {
|
||
const d = await api('GET', 'api/clusters');
|
||
set.clusters = d.clusters;
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
return;
|
||
}
|
||
const list = $('#clusterList');
|
||
list.replaceChildren();
|
||
if (!set.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupText') }));
|
||
for (const c of set.clusters) {
|
||
const chk = h('div', { class: 'chk', hidden: true });
|
||
list.appendChild(h('div', { class: 'citem' + (c.reference ? ' ref' : '') },
|
||
h('div', { class: 'n' }, c.reference ? h('span', { class: 'c-role', text: t('reference') }) : null, h('span', { text: c.name })),
|
||
h('div', { class: 'u', text: c.url + (c.tokenId ? ' · ' + c.tokenId : ''), title: c.url }),
|
||
can.admin() ? h('div', { class: 'acts' },
|
||
h('button', { type: 'button', class: 'btn tool', onclick: () => checkCluster(c.name, chk) }, icon('plug'), t('test')),
|
||
h('button', { type: 'button', class: 'btn tool', onclick: () => editCluster(c) }, icon('pencil'), t('edit'))) : h('span'),
|
||
chk));
|
||
}
|
||
}
|
||
async function checkCluster(name, el) {
|
||
el.hidden = false;
|
||
el.className = 'chk muted';
|
||
el.textContent = t('testing');
|
||
try {
|
||
const r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check');
|
||
const nodes = (r.nodes || []).map((n) => (n.name ? n.name + ' ' : '') + 'PVE ' + n.version).join(', ');
|
||
if (r.error) { el.className = 'chk ko'; el.textContent = '✗ ' + r.error; return; }
|
||
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
|
||
el.textContent = '✓ ' + t('testOk') + (nodes ? ' — ' + nodes : '') + ((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : '');
|
||
} catch (e) {
|
||
el.className = 'chk ko';
|
||
el.textContent = '✗ ' + e.message;
|
||
}
|
||
}
|
||
|
||
let editing = null;
|
||
function editCluster(c) {
|
||
editing = c || null;
|
||
const isNew = !c;
|
||
c = c || { name: '', reference: !set.clusters.some((x) => x.reference), url: '', tokenId: '', fingerprint: '', caPem: '', insecure: false, timeout: '', mapping: {}, policies: {} };
|
||
$('#clusterTitle').textContent = isNew ? t('newCluster') : t('editCluster', { c: c.name });
|
||
$('#cName').value = c.name;
|
||
setSeg('#cRole', c.reference ? 'ref' : 'client');
|
||
$('#cURL').value = c.url;
|
||
$('#cToken').value = c.tokenId;
|
||
$('#cSecret').value = '';
|
||
$('#cSecret').required = isNew || !c.hasSecret;
|
||
$('#cSecretHint').textContent = isNew || !c.hasSecret ? t('secretNew') : t('secretKeep');
|
||
$('#cTLS').value = c.fingerprint ? 'fp' : c.caPem ? 'ca' : c.insecure ? 'insecure' : (isNew ? 'fp' : 'sys');
|
||
$('#cFp').value = c.fingerprint || '';
|
||
$('#cCA').value = c.caPem || '';
|
||
$('#cTimeout').value = c.timeout || '';
|
||
$('#cDelete').hidden = isNew;
|
||
$('#cErr').hidden = true;
|
||
tlsFields();
|
||
roleFields();
|
||
const mr = $('#cMapRows');
|
||
mr.replaceChildren();
|
||
for (const [k, objs] of Object.entries(c.mapping || {})) {
|
||
for (const [id, fields] of Object.entries(objs || {})) {
|
||
for (const [f, v] of Object.entries(fields || {})) mr.appendChild(mapRow(k, id, f, fmtVal(v)));
|
||
}
|
||
}
|
||
const pr = $('#cPolRows');
|
||
pr.replaceChildren();
|
||
for (const [k, fields] of Object.entries(c.policies || {})) {
|
||
for (const [f, p] of Object.entries(fields || {})) pr.appendChild(polRow(k, f, p));
|
||
}
|
||
$('#clusterDlg').showModal();
|
||
$('#cName').focus();
|
||
}
|
||
function setSeg(sel, v) { $$(sel + ' [data-v]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.v === v))); }
|
||
function segVal(sel) { const b = $(sel + ' [aria-checked="true"]'); return b ? b.dataset.v : ''; }
|
||
function tlsFields() {
|
||
const v = $('#cTLS').value;
|
||
$$('#clusterDlg [data-tls]').forEach((e) => { e.hidden = e.dataset.tls !== v; });
|
||
}
|
||
function roleFields() { $('#cClientOnly').hidden = segVal('#cRole') === 'ref'; }
|
||
|
||
function kindSelect(v) {
|
||
const s = h('select', { class: 'field' }, ...KINDS.map((k) => h('option', { value: k, text: t('kindShort.' + k) })));
|
||
s.value = v || KINDS[1];
|
||
return s;
|
||
}
|
||
function refIds(kind) {
|
||
const st = S.state;
|
||
if (!st) return [];
|
||
const ids = new Set();
|
||
for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) ids.add(r.id);
|
||
return Array.from(ids).sort();
|
||
}
|
||
function knownFields(kind) {
|
||
const st = S.state;
|
||
const f = new Set();
|
||
if (st) for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) for (const x of r.fields || []) f.add(x.name);
|
||
if (kind === 'sdn-zone') ['bridge', 'nodes', 'peers', 'fabric', 'dns', 'reversedns', 'dnszone', 'mtu'].forEach((x) => f.add(x));
|
||
return Array.from(f).sort();
|
||
}
|
||
let dlN = 0;
|
||
function datalist(values) {
|
||
const id = 'dl' + (++dlN);
|
||
return [id, h('datalist', { id }, ...values.map((v) => h('option', { value: v })))];
|
||
}
|
||
function mapRow(kind, id, field, value) {
|
||
const ks = kindSelect(kind);
|
||
const [idl, idList] = datalist(refIds(ks.value));
|
||
const [fdl, fList] = datalist(knownFields(ks.value));
|
||
const row = h('div', { class: 'erow map' }, ks,
|
||
h('input', { class: 'inp mono', list: idl, value: kind === 'datacenter-options' && !id ? 'datacenter' : id || '', placeholder: 'id', 'data-k': 'id' }),
|
||
h('input', { class: 'inp mono', list: fdl, value: field || '', placeholder: 'bridge', 'data-k': 'field' }),
|
||
h('input', { class: 'inp mono', value: value || '', placeholder: 'vmbr1', 'data-k': 'value' }),
|
||
iconBtn('trash', t('delete'), () => row.remove(), 'danger'), idList, fList);
|
||
ks.addEventListener('change', () => {
|
||
idList.replaceChildren(...refIds(ks.value).map((v) => h('option', { value: v })));
|
||
fList.replaceChildren(...knownFields(ks.value).map((v) => h('option', { value: v })));
|
||
if (ks.value === 'datacenter-options') $('[data-k="id"]', row).value = 'datacenter';
|
||
});
|
||
return row;
|
||
}
|
||
function polRow(kind, field, pol, onchange) {
|
||
const ks = kindSelect(kind);
|
||
const [fdl, fList] = datalist(knownFields(ks.value));
|
||
const ps = h('select', { class: 'field', 'data-k': 'policy' }, ...POLICIES.map((p) => h('option', { value: p, text: p })));
|
||
ps.value = pol || 'ignore';
|
||
const row = h('div', { class: 'erow pol' }, ks,
|
||
h('input', { class: 'inp mono', list: fdl, value: field || '', placeholder: 'mtu', 'data-k': 'field' }),
|
||
ps, iconBtn('trash', t('delete'), () => { row.remove(); if (onchange) onchange(); }, 'danger'), fList);
|
||
ks.addEventListener('change', () => fList.replaceChildren(...knownFields(ks.value).map((v) => h('option', { value: v }))));
|
||
if (onchange) row.addEventListener('input', onchange);
|
||
if (onchange) row.addEventListener('change', onchange);
|
||
return row;
|
||
}
|
||
function readPolicies(container) {
|
||
const out = {};
|
||
for (const row of $$('.erow.pol', container)) {
|
||
const k = $('select', row).value;
|
||
const f = $('[data-k="field"]', row).value.trim();
|
||
if (!f) continue;
|
||
(out[k] = out[k] || {})[f] = $('[data-k="policy"]', row).value;
|
||
}
|
||
return out;
|
||
}
|
||
function parseValue(v) {
|
||
v = v.trim();
|
||
if (v.startsWith('[') && v.endsWith(']')) {
|
||
try { const a = JSON.parse(v); if (Array.isArray(a)) return a.map(String); } catch (e) { /* plain text */ }
|
||
return v.slice(1, -1).split(',').map((x) => x.trim()).filter(Boolean);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
async function saveCluster(ev) {
|
||
ev.preventDefault();
|
||
const tls = $('#cTLS').value;
|
||
const c = {
|
||
name: $('#cName').value.trim(),
|
||
reference: segVal('#cRole') === 'ref',
|
||
position: editing ? editing.position : set.clusters.length,
|
||
url: $('#cURL').value.trim(),
|
||
tokenId: $('#cToken').value.trim(),
|
||
secret: $('#cSecret').value.trim(),
|
||
hasSecret: false,
|
||
fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '',
|
||
caPem: tls === 'ca' ? $('#cCA').value.trim() : '',
|
||
insecure: tls === 'insecure',
|
||
timeout: $('#cTimeout').value.trim(),
|
||
mapping: {},
|
||
policies: {},
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
if (!c.reference) {
|
||
for (const row of $$('#cMapRows .erow')) {
|
||
const k = $('select', row).value;
|
||
const id = $('[data-k="id"]', row).value.trim();
|
||
const f = $('[data-k="field"]', row).value.trim();
|
||
if (!id || !f) continue;
|
||
((c.mapping[k] = c.mapping[k] || {})[id] = c.mapping[k][id] || {})[f] = parseValue($('[data-k="value"]', row).value);
|
||
}
|
||
c.policies = readPolicies($('#cPolRows'));
|
||
}
|
||
if (!c.secret) delete c.secret;
|
||
try {
|
||
await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c);
|
||
$('#clusterDlg').close();
|
||
toast(t('saved'));
|
||
loadClusters();
|
||
loadState(true);
|
||
} catch (e) {
|
||
$('#cErr').textContent = e.message;
|
||
$('#cErr').hidden = false;
|
||
}
|
||
}
|
||
async function deleteCluster() {
|
||
if (!editing || !confirm(t('confirmDelete', { c: editing.name }))) return;
|
||
try {
|
||
await api('DELETE', 'api/clusters/' + encodeURIComponent(editing.name));
|
||
$('#clusterDlg').close();
|
||
loadClusters();
|
||
loadState(true);
|
||
} catch (e) {
|
||
$('#cErr').textContent = e.message;
|
||
$('#cErr').hidden = false;
|
||
}
|
||
}
|
||
|
||
// synchronization settings (Save / Cancel like Logstream)
|
||
async function loadSettings() {
|
||
try {
|
||
const d = await api('GET', 'api/settings');
|
||
set.settings = d.settings;
|
||
set.defaults = d.defaults || {};
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
return;
|
||
}
|
||
renderSettings();
|
||
setDirty(false);
|
||
}
|
||
function renderSettings() {
|
||
const st = set.settings;
|
||
const chips = $('#kindChips');
|
||
chips.replaceChildren(...KINDS.map((k) => h('button', {
|
||
type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)),
|
||
onclick: (ev) => { ev.currentTarget.classList.toggle('on'); ev.currentTarget.setAttribute('aria-pressed', String(ev.currentTarget.classList.contains('on'))); setDirty(true); },
|
||
}, t('kinds.' + k))));
|
||
$('#pruneSw').checked = !!st.prune;
|
||
const sel = $('#selRows');
|
||
sel.replaceChildren(...KINDS.map((k) => {
|
||
const o = (st.objects || {})[k] || {};
|
||
return h('div', { class: 'erow sel', dataset: { kind: k } },
|
||
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
||
h('input', { class: 'inp mono', value: (o.include || []).join(', '), placeholder: '*', 'data-k': 'include', oninput: () => setDirty(true) }),
|
||
h('input', { class: 'inp mono', value: (o.exclude || []).join(', '), placeholder: 'lab*', 'data-k': 'exclude', oninput: () => setDirty(true) }));
|
||
}));
|
||
$('#polDefaults').replaceChildren(...KINDS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), set.defaults[k] || '')));
|
||
const pr = $('#polRows');
|
||
pr.replaceChildren();
|
||
for (const [k, fields] of Object.entries(st.policies || {})) {
|
||
for (const [f, p] of Object.entries(fields || {})) pr.appendChild(polRow(k, f, p, () => setDirty(true)));
|
||
}
|
||
}
|
||
function setDirty(on) {
|
||
set.dirty = on;
|
||
$('#setActions').classList.toggle('dirty', on);
|
||
$('#setSave').disabled = !on;
|
||
$('#setCancel').disabled = !on;
|
||
$('#setState').textContent = on ? t('unsaved') : '';
|
||
}
|
||
async function saveSettings() {
|
||
const splitP = (v) => v.split(',').map((x) => x.trim()).filter(Boolean);
|
||
const objects = {};
|
||
for (const row of $$('#selRows .erow')) {
|
||
const inc = splitP($('[data-k="include"]', row).value);
|
||
const exc = splitP($('[data-k="exclude"]', row).value);
|
||
if (inc.length || exc.length) objects[row.dataset.kind] = { include: inc, exclude: exc };
|
||
}
|
||
const body = {
|
||
kinds: $$('#kindChips .chip.on').map((c) => c.dataset.kind),
|
||
objects,
|
||
policies: readPolicies($('#polRows')),
|
||
prune: $('#pruneSw').checked,
|
||
};
|
||
try {
|
||
await api('PUT', 'api/settings', body);
|
||
setDirty(false);
|
||
$('#setState').textContent = t('saved');
|
||
loadState(true);
|
||
} catch (e) {
|
||
$('#setState').textContent = t('saveFailed', { e: e.message });
|
||
}
|
||
}
|
||
|
||
// users
|
||
async function loadUsers() {
|
||
let users;
|
||
try {
|
||
users = (await api('GET', 'api/users')).users;
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
return;
|
||
}
|
||
$('#userRows').replaceChildren(...users.map((u) => userRow(u)));
|
||
}
|
||
function userRow(u) {
|
||
const isNew = !u;
|
||
u = u || { name: '', role: 'viewer' };
|
||
const name = h('input', { class: 'inp mono', value: u.name, readonly: !isNew, placeholder: 'prenom.nom', autocomplete: 'off', 'data-k': 'name' });
|
||
const role = h('select', { class: 'field' }, ...ROLES.map((r) => h('option', { value: r, text: t('role.' + r) })));
|
||
role.value = u.role;
|
||
const pwd = h('input', { class: 'inp', type: 'password', autocomplete: 'new-password', placeholder: isNew ? t('pwdRequired') : t('pwdKeep') });
|
||
const row = h('div', { class: 'erow user' }, name, role, pwd,
|
||
h('span', { class: 'muted small', text: u.lastLogin ? fmtTime(u.lastLogin, true) : t('never') }),
|
||
h('span', { class: 'acts' },
|
||
iconBtn('check', t('save'), async () => {
|
||
try {
|
||
await api('PUT', 'api/users/' + encodeURIComponent(name.value.trim()), { role: role.value, password: pwd.value });
|
||
toast(t('saved'));
|
||
loadUsers();
|
||
} catch (e) { toast(e.message, true); }
|
||
}),
|
||
isNew ? iconBtn('x', t('cancel'), () => row.remove()) : iconBtn('trash', t('delete'), async () => {
|
||
if (!confirm(t('confirmDeleteUser', { u: u.name }))) return;
|
||
try { await api('DELETE', 'api/users/' + encodeURIComponent(u.name)); loadUsers(); } catch (e) { toast(e.message, true); }
|
||
}, 'danger')));
|
||
return row;
|
||
}
|
||
|
||
// import
|
||
async function importConfig() {
|
||
const res = $('#importRes');
|
||
res.replaceChildren();
|
||
try {
|
||
const d = await api('POST', 'api/config/import', { yaml: $('#importText').value, replace: $('#importReplace').checked });
|
||
res.appendChild(note('info', 'OK', t('importOk')));
|
||
for (const w of d.warnings || []) res.appendChild(note('warn', t('warning'), w));
|
||
loadState(true);
|
||
} catch (e) {
|
||
res.appendChild(note('err', t('st.error'), e.message));
|
||
}
|
||
}
|
||
|
||
// account
|
||
function renderAccount() {
|
||
const me = S.me;
|
||
$('#accountInfo').replaceChildren(
|
||
h('dt', { class: 'lbl', text: t('accUser') }), h('dd', { class: 'mono', text: me.user }),
|
||
h('dt', { class: 'lbl', text: t('accRole') }), h('dd', { text: t('role.' + me.role) }),
|
||
h('dt', { class: 'lbl', text: t('accMode') }), h('dd', { text: me.mode === 'oidc' ? t('modeOidc') : t('modeLocal') }));
|
||
$('#pwdForm').hidden = me.mode !== 'local';
|
||
let p = $('#pwdSection .oidc-note');
|
||
if (me.mode !== 'local' && !p) $('#pwdSection').appendChild(h('p', { class: 'muted oidc-note', text: t('pwdOidc') }));
|
||
}
|
||
async function changePassword(ev) {
|
||
ev.preventDefault();
|
||
if ($('#pwdNew').value !== $('#pwdNew2').value) { toast(t('pwdMismatch'), true); return; }
|
||
try {
|
||
await api('POST', 'api/account/password', { old: $('#pwdOld').value, new: $('#pwdNew').value });
|
||
$('#pwdForm').reset();
|
||
toast(t('pwdDone'));
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
// ---------- events ----------
|
||
function bind() {
|
||
$('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); });
|
||
$('#settingsBtn').addEventListener('click', () => openSettings());
|
||
$('#setupBtn').addEventListener('click', () => openSettings('clusters'));
|
||
$('#themeBtn').addEventListener('click', () => {
|
||
const root = document.documentElement;
|
||
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||
setTheme(dark ? 'light' : 'dark');
|
||
});
|
||
$$('#viewMode [data-view]').forEach((b) => b.addEventListener('click', () => setView(b.dataset.view)));
|
||
$('#kindSel').addEventListener('change', (e) => { S.kind = e.target.value; e.target.classList.toggle('set', !!S.kind); renderClient(); });
|
||
$('#statusSel').addEventListener('change', (e) => { S.status = e.target.value; store.set('status', S.status); e.target.classList.toggle('set', S.status !== 'diff'); renderClient(); });
|
||
$('#runModeSel').addEventListener('change', (e) => { S.runMode = e.target.value; renderRuns(); });
|
||
let qTimer;
|
||
$('#q').addEventListener('input', (e) => {
|
||
clearTimeout(qTimer);
|
||
qTimer = setTimeout(() => { S.q = e.target.value.trim(); renderView(); }, 120);
|
||
});
|
||
$('#dryAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }));
|
||
$('#syncAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }, true));
|
||
$('#planApply').addEventListener('click', applyPlan);
|
||
$('#runMore').addEventListener('click', () => loadRuns(false));
|
||
$('#auditMore').addEventListener('click', () => loadAudit(false));
|
||
$$('dialog [data-close]').forEach((b) => b.addEventListener('click', () => b.closest('dialog').close()));
|
||
$('#planDlg').addEventListener('close', () => clearInterval(plan.poll));
|
||
|
||
// settings
|
||
$$('#setNav [data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
|
||
$('#addCluster').addEventListener('click', () => editCluster(null));
|
||
$('#clusterForm').addEventListener('submit', saveCluster);
|
||
$('#cDelete').addEventListener('click', deleteCluster);
|
||
$('#cTLS').addEventListener('change', tlsFields);
|
||
$$('#cRole [data-v]').forEach((b) => b.addEventListener('click', () => { setSeg('#cRole', b.dataset.v); roleFields(); }));
|
||
$('#cMapAdd').addEventListener('click', () => $('#cMapRows').appendChild(mapRow('sdn-zone', '', '', '')));
|
||
$('#cPolAdd').addEventListener('click', () => $('#cPolRows').appendChild(polRow('sdn-zone', '', 'ignore')));
|
||
$('#polAdd').addEventListener('click', () => { $('#polRows').appendChild(polRow('datacenter-options', '', 'sync', () => setDirty(true))); setDirty(true); });
|
||
$('#pruneSw').addEventListener('change', () => setDirty(true));
|
||
$('#setSave').addEventListener('click', saveSettings);
|
||
$('#setCancel').addEventListener('click', () => { renderSettings(); setDirty(false); });
|
||
$('#userAdd').addEventListener('click', () => { const r = userRow(null); $('#userRows').appendChild(r); $('input', r).focus(); });
|
||
$('#importPick').addEventListener('click', () => $('#importFile').click());
|
||
$('#importFile').addEventListener('change', async (e) => {
|
||
const f = e.target.files[0];
|
||
if (f) $('#importText').value = await f.text();
|
||
});
|
||
$('#importBtn').addEventListener('click', importConfig);
|
||
$('#pwdForm').addEventListener('submit', changePassword);
|
||
$$('#langSwitch [data-lang]').forEach((b) => b.addEventListener('click', () => { lang = b.dataset.lang; store.set('lang', lang); applyLang(); }));
|
||
$$('#themeSwitch [data-theme-opt]').forEach((b) => b.addEventListener('click', () => setTheme(b.dataset.themeOpt)));
|
||
$$('#densitySwitch [data-density]').forEach((b) => b.addEventListener('click', () => setDensity(b.dataset.density)));
|
||
|
||
// sticky table headers under the top bar; "back to top"
|
||
const top = () => document.documentElement.style.setProperty('--topbar-h', $('.topbar').offsetHeight + 'px');
|
||
window.addEventListener('resize', top);
|
||
top();
|
||
window.addEventListener('scroll', () => { $('#toTop').hidden = window.scrollY < 400; }, { passive: true });
|
||
$('#toTop').addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
||
window.addEventListener('hashchange', () => { readHash(); renderView(); });
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === '/' && !e.target.closest('input, textarea, select, dialog')) { e.preventDefault(); $('#q').focus(); }
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
bind();
|
||
setDensity(store.get('density') || 'normal');
|
||
try {
|
||
S.me = await api('GET', 'auth/me');
|
||
} catch (e) {
|
||
if (e.message !== 'auth') showError(e.message);
|
||
return;
|
||
}
|
||
const w = $('#whoami');
|
||
w.hidden = false;
|
||
$('.who-name', w).textContent = S.me.user;
|
||
const badge = $('.who-badge', w);
|
||
badge.className = 'who-badge ' + S.me.role;
|
||
badge.textContent = t('role.' + S.me.role) + (S.me.mode === 'oidc' ? ' · OIDC' : '');
|
||
$('#demoBadge').hidden = !S.me.demo;
|
||
$('#auditTab').hidden = !can.admin();
|
||
$('#ver').textContent = 'pve-confsync ' + S.me.version;
|
||
readHash();
|
||
applyLang();
|
||
await loadState(false);
|
||
if (S.view === 'runs') loadRuns(true);
|
||
if (S.view === 'audit') loadAudit(true);
|
||
}
|
||
init();
|
||
})();
|