// 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/, #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(); })();