diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..9251d2c --- /dev/null +++ b/web/app.js @@ -0,0 +1,1366 @@ +// 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(); +})();