Files
pve-confsync/web/app.js
T

1367 lines
72 KiB
JavaScript
Raw Blame History

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