Deuxième mode de réplication, séparé de la réplication cluster vers cluster
(sélecteur « Clusters (API) | Nœuds (fichiers) » en haut de page) : un profil
réplique la configuration système d'un nœud maître vers des nœuds cibles.
- réseau par /nodes/{node}/network (interfaces.new puis rechargement), adresses
et passerelles propres à chaque nœud, mapping des noms d'interfaces par cible ;
DNS par /nodes/{node}/dns ; multipath.conf, wwids (ajout seul), chrony,
sysctl.d, interfaces.d (hors sdn) par SSH
- découverte des nœuds (/cluster/status), accès SSH par nœud (mot de passe du
cluster ou propre), clé d'hôte épinglée, contrôle du nom du nœud
- dry-run avec diff des fichiers ; apply (admin) avec contrôle SHA-256,
sauvegarde et restore.sh sur le nœud, écriture atomique, rechargements,
restauration si un rechargement échoue, timer systemd de restauration si le
nœud est injoignable après le rechargement du réseau, confirmation tapée
- vue Nœuds et profils, journal séparé par mode, démo avec nœuds simulés
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2266 lines
129 KiB
JavaScript
2266 lines
129 KiB
JavaScript
// pve-confsync web UI: clusters (root credentials, primary and targets), replicated
|
||
// items, differences per target, dry-run and synchronization (per object, per
|
||
// target, towards several targets), 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 le primaire et les cibles', settings: 'Paramètres', theme: 'Thème clair / sombre',
|
||
logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewClusters: 'Clusters', viewRuns: 'Journal', viewAudit: 'Audit',
|
||
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Répliquer',
|
||
targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.',
|
||
setupClusters: 'Base vide : ajoutez vos clusters (URL et mot de passe root), puis choisissez le primaire.', setupClustersBtn: 'Ajouter des clusters',
|
||
setupPrimary: 'Aucun cluster primaire : choisissez la source de la réplication dans la vue Clusters.', setupPrimaryBtn: 'Choisir le primaire',
|
||
stripAria: 'Clusters', colKind: 'Type', colObject: 'Objet', colStatus: 'Statut', colFields: 'Champs',
|
||
colDate: 'Date', colUser: 'Utilisateur', colMode: 'Mode', colScope: 'Portée', colClients: 'Cibles', 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: 'Répliquer', addRow: '+ Ajouter',
|
||
kinds: {
|
||
'datacenter-options': 'Options datacenter', 'pve-group': 'Groupes', 'pve-role': 'Rôles personnalisés',
|
||
'pve-user': 'Comptes du realm pve et mots de passe', 'pve-password': 'Mots de passe du realm pve',
|
||
'sdn-zone': 'Zones SDN (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
||
},
|
||
kindShort: {
|
||
'datacenter-options': 'options', 'pve-group': 'groupe', 'pve-role': 'rôle', 'pve-user': 'compte', 'pve-password': 'mdp',
|
||
'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 primaire', error: 'Erreur' },
|
||
filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs',
|
||
reference: 'PRIMAIRE', cst: { ok: 'À JOUR', diff: 'ÉCARTS', error: 'BLOQUANT' }, unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Répliquer vers la cible',
|
||
dryObj: 'Dry-run de cet objet', syncObj: 'Répliquer cet objet',
|
||
refError: 'Primaire illisible', clientError: 'Cible illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||
fieldsN: '{n} champ(s)', objectsN: '{n} objet(s)', noFields: 'aucun champ',
|
||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après réplication',
|
||
emptyOk: 'Aucun écart pour cette cible.', emptyOkHint: 'La cible est conforme au primaire pour les éléments répliqués.',
|
||
emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.',
|
||
noClient: 'Aucune cible.', noClientHint: 'Ajoutez des clusters cibles dans la vue Clusters.',
|
||
connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Primaire {ref} · {n} cible(s) · {d} avec écarts · lu à {t}',
|
||
dryTitle: 'Dry-run : {what}', whatGlobal: 'toutes les cibles', whatTargets: '{l}', whatClient: 'cible {c}', whatObject: '{o} sur {c}',
|
||
reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur cette cible.',
|
||
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 cibles. Tapez le nom du cluster primaire « {w} » pour confirmer.",
|
||
applyNow: 'Appliquer', applying: 'Réplication en cours…', applyDone: 'Réplication 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: 'réplication' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
||
allModes: 'Tous les modes', onlyApply: 'Réplications', onlyDry: 'Dry-runs',
|
||
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
||
action: {
|
||
setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'réplication lancée', apply: 'réplication',
|
||
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
|
||
'cluster-role': 'rôle du cluster', 'cluster-forget': 'empreintes oubliées', 'settings-save': 'paramètres',
|
||
'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe',
|
||
},
|
||
tabSync: 'Réplication', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
|
||
addCluster: '+ Ajouter un cluster', addBulk: '+ Ajouter une liste', testAll: 'Tout tester',
|
||
clustersHelp: "Chaque cluster est joint avec root@pam : l'API pour la configuration, SSH (root, même mot de passe) pour ce que l'API ne couvre pas (mots de passe du realm pve). Le certificat TLS et la clé d'hôte SSH sont enregistrés au premier test, puis vérifiés. Le primaire n'est jamais modifié. Les mots de passe sont chiffrés en base (AES-256-GCM) et 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' },
|
||
crole: { primary: 'primaire', target: 'cible', disabled: 'désactivé' }, makePrimary: 'Définir comme primaire',
|
||
neverTested: 'jamais testé', testedAt: 'testé le {t}', pinned: 'enregistré : {l}', trustKey: 'Faire confiance à la nouvelle clé',
|
||
confirmTrust: "La clé d'hôte SSH de {c} a changé ({fp}). Ne l'acceptez que si le nœud a été réinstallé ou remplacé. Faire confiance à cette clé ?",
|
||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle',
|
||
rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Désactivé',
|
||
cUser: 'Utilisateur', cPassword: 'Mot de passe root', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||
cSSHHost: 'Nœud SSH', cSSHPort: 'Port SSH', cSSHKey: "Clé d'hôte SSH", sshKeyNone: 'enregistrée au premier test',
|
||
fpPh: 'enregistrée au premier test', sshHostPh: "hôte de l'URL",
|
||
tlsFp: 'Empreinte du certificat (enregistrée au premier test)', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)',
|
||
passwordKeep: 'Laisser vide pour conserver le mot de passe enregistré.', passwordNew: 'Mot de passe de root (API root@pam et SSH), chiffré en base.',
|
||
forget: 'Oublier les empreintes', confirmForget: "Oublier le certificat TLS et la clé SSH enregistrés pour {c} ? Ils seront enregistrés de nouveau au prochain test.",
|
||
cMapping: 'Mapping (valeurs propres à la cible)', cMappingHelp: "Valeurs prioritaires sur le primaire pour cette cible : bridge, nodes, peers d'une zone, next-id… Les champs en politique « override » ne sont écrits qu'à partir d'ici.",
|
||
cPolicies: 'Politiques propres à la cible', cPoliciesHelp: 'Prioritaires sur les politiques globales pour cette cible.',
|
||
confirmDelete: 'Supprimer le cluster {c} ? Son mapping est perdu.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
||
bulkTitle: 'Ajouter une liste de clusters', bulkHelp: "Un cluster par ligne : « nom URL », ou l'URL seule (le nom est alors l'hôte). Tous sont ajoutés comme cibles avec le même mot de passe root, puis testés ; choisissez ensuite le primaire.",
|
||
bulkPassword: 'Mot de passe root', bulkGo: 'Ajouter et tester', bulkBadLine: 'ligne illisible : {l}',
|
||
kindsTitle: 'Éléments répliqués', kindsHelp: 'Les comptes du realm pve vont avec leurs mots de passe et ont besoin de leurs groupes. Hors périmètre : EVPN, firewall, storage, ACL, jobs, tokens API, comptes pam et des autres realms.',
|
||
pruneTitle: 'Suppression', pruneLabel: 'Supprimer sur les cibles les objets absents du primaire',
|
||
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-?, *@pve). Vide : tout.",
|
||
polTitle: 'Politiques par champ', polHelp: 'sync : copie du primaire ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping de la cible. Défauts :',
|
||
unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}',
|
||
usersHelp: "Comptes locaux de pve-confsync (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et réplication), 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',
|
||
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 répliquer.', errorPrefix: 'Erreur : ',
|
||
planKinds: 'Éléments répliqués', kindOn: 'Répliquer', kindOff: 'Ne pas répliquer',
|
||
planKindsHelp: 'Choix enregistré pour toutes les réplications. Les comptes du realm pve vont avec leurs mots de passe et ont besoin de leurs groupes. Changer un choix relance le dry-run.',
|
||
planKindsRO: 'Seuls les administrateurs changent les éléments répliqués.',
|
||
redacted: 'masqué',
|
||
polDef: {
|
||
'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais',
|
||
'pve-group': 'sync (commentaire ; les membres viennent des comptes)',
|
||
'pve-role': 'sync (rôles personnalisés ; les rôles intégrés ne sont pas touchés)',
|
||
'pve-user': 'sync (nom, e-mail, groupes, expiration, activation… ; mot de passe : hachage copié par SSH dans /etc/pve/priv/shadow.cfg, jamais affiché)',
|
||
'sdn-zone': 'nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping de la cible) ; le reste : sync',
|
||
'sdn-vnet': 'sync', 'sdn-subnet': 'sync',
|
||
},
|
||
},
|
||
en: {
|
||
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
||
refreshTitle: 'Read the primary and the targets again', settings: 'Settings', theme: 'Light / dark theme',
|
||
logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewClusters: 'Clusters', viewRuns: 'Journal', viewAudit: 'Audit',
|
||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Replicate',
|
||
targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.',
|
||
setupClusters: 'Empty database: add your clusters (URL and root password), then choose the primary.', setupClustersBtn: 'Add clusters',
|
||
setupPrimary: 'No primary cluster: choose the source of the replication in the Clusters view.', setupPrimaryBtn: 'Choose the primary',
|
||
stripAria: 'Clusters', colKind: 'Kind', colObject: 'Object', colStatus: 'Status', colFields: 'Fields',
|
||
colDate: 'Date', colUser: 'User', colMode: 'Mode', colScope: 'Scope', colClients: 'Targets', 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: 'Replicate', addRow: '+ Add',
|
||
kinds: {
|
||
'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
|
||
'pve-user': 'Accounts of the pve realm and passwords', 'pve-password': 'Passwords of the pve realm',
|
||
'sdn-zone': 'SDN zones (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
||
},
|
||
kindShort: {
|
||
'datacenter-options': 'options', 'pve-group': 'group', 'pve-role': 'role', 'pve-user': 'account', 'pve-password': 'passwd',
|
||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||
},
|
||
allKinds: 'All kinds',
|
||
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not on primary', error: 'Error' },
|
||
filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors',
|
||
reference: 'PRIMARY', cst: { ok: 'IN SYNC', diff: 'DIFFS', error: 'BLOCKED' }, unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Replicate to target',
|
||
dryObj: 'Dry-run this object', syncObj: 'Replicate this object',
|
||
refError: 'Primary unreadable', clientError: 'Target unreadable', planError: 'Blocking', warning: 'Warning',
|
||
fieldsN: '{n} field(s)', objectsN: '{n} object(s)', noFields: 'no field',
|
||
thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After replication',
|
||
emptyOk: 'No difference for this target.', emptyOkHint: 'The target is in line with the primary for the replicated items.',
|
||
emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.',
|
||
noClient: 'No target.', noClientHint: 'Add target clusters in the Clusters view.',
|
||
connOk: 'Connected', connKo: 'Offline', statsLine: 'Primary {ref} · {n} target(s) · {d} with differences · read at {t}',
|
||
dryTitle: 'Dry-run: {what}', whatGlobal: 'all targets', whatTargets: '{l}', whatClient: 'target {c}', whatObject: '{o} on {c}',
|
||
reading: 'Reading the clusters…', planEmpty: 'Nothing to do: in sync.', planErrors: 'Blocking errors: nothing will be applied on this target.',
|
||
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 targets. Type the name of the primary cluster "{w}" to confirm.',
|
||
applyNow: 'Apply', applying: 'Replication in progress…', applyDone: 'Replication 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: 'replication' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
||
allModes: 'All modes', onlyApply: 'Replications', onlyDry: 'Dry-runs',
|
||
noRuns: 'No run yet.', noAudit: 'No event.',
|
||
action: {
|
||
setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'replication started', apply: 'replication',
|
||
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
|
||
'cluster-role': 'cluster role', 'cluster-forget': 'fingerprints forgotten', 'settings-save': 'settings',
|
||
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
|
||
},
|
||
tabSync: 'Replication', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||
roNote: 'Read only: only administrators change these settings.',
|
||
addCluster: '+ Add a cluster', addBulk: '+ Add a list', testAll: 'Test all',
|
||
clustersHelp: 'Each cluster is reached as root@pam: the API for the configuration, SSH (root, same password) for what the API does not cover (passwords of the pve realm). The TLS certificate and the SSH host key are recorded on the first test, then checked. The primary is never written. Passwords are encrypted in the database (AES-256-GCM) and never shown again.',
|
||
test: 'Test', edit: 'Edit', testing: 'Testing…', testOk: 'Access OK', role: { admin: 'admin', operator: 'operator', viewer: 'read only' },
|
||
crole: { primary: 'primary', target: 'target', disabled: 'disabled' }, makePrimary: 'Make primary',
|
||
neverTested: 'never tested', testedAt: 'tested on {t}', pinned: 'recorded: {l}', trustKey: 'Trust the new key',
|
||
confirmTrust: 'The SSH host key of {c} changed ({fp}). Only accept it if the node was reinstalled or replaced. Trust this key?',
|
||
newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role',
|
||
rolePrimary: 'Primary', roleTarget: 'Target', roleDisabled: 'Disabled',
|
||
cUser: 'User', cPassword: 'Root password', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout',
|
||
cSSHHost: 'SSH node', cSSHPort: 'SSH port', cSSHKey: 'SSH host key', sshKeyNone: 'recorded on the first test',
|
||
fpPh: 'recorded on the first test', sshHostPh: 'host of the URL',
|
||
tlsFp: 'Certificate fingerprint (recorded on the first test)', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)',
|
||
passwordKeep: 'Leave empty to keep the stored password.', passwordNew: 'Password of root (API root@pam and SSH), encrypted in the database.',
|
||
forget: 'Forget fingerprints', confirmForget: 'Forget the TLS certificate and SSH key recorded for {c}? They are recorded again on the next test.',
|
||
cMapping: 'Mapping (target specific values)', cMappingHelp: 'Values taking precedence over the primary for this target: bridge, nodes, peers of a zone, next-id… Fields with the "override" policy are only written from here.',
|
||
cPolicies: 'Target policies', cPoliciesHelp: 'Take precedence over the global policies for this target.',
|
||
confirmDelete: 'Delete cluster {c}? Its mapping is lost.', confirmDeleteUser: 'Delete user {u}?',
|
||
bulkTitle: 'Add a list of clusters', bulkHelp: 'One cluster per line: "name URL", or the URL alone (the name is then the host). All are added as targets with the same root password, then tested; choose the primary afterwards.',
|
||
bulkPassword: 'Root password', bulkGo: 'Add and test', bulkBadLine: 'unreadable line: {l}',
|
||
kindsTitle: 'Replicated items', kindsHelp: 'The accounts of the pve realm go with their passwords and need their groups. Out of scope: EVPN, firewall, storage, ACL, jobs, API tokens, accounts of pam and the other realms.',
|
||
pruneTitle: 'Deletion', pruneLabel: 'Delete on the targets the objects absent from the primary',
|
||
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-?, *@pve). Empty: everything.',
|
||
polTitle: 'Field policies', polHelp: 'sync: copied from the primary; ignore: never read nor written; override: value from the target mapping. Defaults:',
|
||
unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}',
|
||
usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and replication), read only. OIDC users get their role from their groups.',
|
||
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
|
||
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 replicate.', errorPrefix: 'Error: ',
|
||
planKinds: 'Replicated items', kindOn: 'Replicate', kindOff: 'Do not replicate',
|
||
planKindsHelp: 'Choice saved for every replication. The accounts of the pve realm go with their passwords and need their groups. Changing a choice runs the dry-run again.',
|
||
planKindsRO: 'Only administrators change the replicated items.',
|
||
redacted: 'hidden',
|
||
polDef: {
|
||
'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id: sync; the rest: ignore; webauthn, u2f, http_proxy: never',
|
||
'pve-group': 'sync (comment; the members come from the accounts)',
|
||
'pve-role': 'sync (custom roles; the built-in roles are not touched)',
|
||
'pve-user': 'sync (name, e-mail, groups, expiry, enabled…; password: hash copied over SSH into /etc/pve/priv/shadow.cfg, never shown)',
|
||
'sdn-zone': 'nodes, bridge, peers, fabric, dns, reversedns, dnszone: override (target mapping); the rest: sync',
|
||
'sdn-vnet': 'sync', 'sdn-subnet': 'sync',
|
||
},
|
||
},
|
||
};
|
||
// node replication: the second mode (system files of the nodes)
|
||
Object.assign(I18N.fr, {
|
||
modeAriaSw: 'Type de réplication', modeCluster: 'Clusters (API)', modeNode: 'Nœuds (fichiers)',
|
||
modeClusterTitle: "Réplication cluster vers cluster par l'API : options datacenter, comptes, SDN",
|
||
modeNodeTitle: 'Réplication nœud vers nœud : réseau et DNS (API), multipath, chrony, sysctl (SSH)',
|
||
viewNodes: 'Nœuds et profils', profileAria: 'Profil de réplication', nreference: 'MAÎTRE', thMaster: 'Maître',
|
||
nodesHelp: "Un profil réplique la configuration système d'un nœud maître vers des nœuds cibles, du même cluster ou d'un autre. Le réseau et le DNS passent par l'API PVE, les fichiers par SSH en root sur chaque nœud (mot de passe du cluster, ou propre au nœud). La clé d'hôte SSH de chaque nœud est enregistrée au premier test. Le maître n'est jamais modifié.",
|
||
addProfile: '+ Nouveau profil', discover: 'Découvrir les nœuds', profilesTitle: 'Profils de réplication', nodesTitle: 'Nœuds',
|
||
noProfiles: 'Aucun profil.', noNodes: "Aucun nœud connu : « Découvrir les nœuds » lit les nœuds de chaque cluster (adresse dans le cluster).",
|
||
setupNodes: 'Aucun profil de réplication de nœuds : découvrez les nœuds, puis créez un profil (nœud maître, cibles, éléments).', setupNodesBtn: 'Créer un profil',
|
||
nodeHelp: "Adresse SSH du nœud : vide, son adresse dans le cluster. Le mot de passe root est celui du cluster, sauf s'il en a un propre. Changer d'adresse oublie la clé d'hôte enregistrée.",
|
||
nHost: 'Adresse SSH', nPassword: 'Mot de passe root', nClear: 'Utiliser le mot de passe du cluster', forgetKey: 'Oublier la clé',
|
||
editNode: 'Nœud {n}', nodePwdKeep: 'Vide : mot de passe inchangé ({w}).', pwdCluster: 'celui du cluster', pwdOwn: 'propre au nœud',
|
||
newProfile: 'Nouveau profil', editProfile: 'Profil {p}', pMaster: 'Nœud maître', pTargets: 'Nœuds cibles', pItems: 'Éléments répliqués',
|
||
pItemsHelp: "Réseau et DNS : API PVE (interfaces.new puis rechargement). Les autres : SSH en root, sauvegarde du fichier sur le nœud, écriture atomique, rechargement (sysctl --system, systemctl restart chrony, multipathd reconfigure, ifreload -a). wwids : ajout seul. Jamais répliqués : /etc/pve, corosync, hostname, clés SSH, interfaces.d/sdn (généré par le SDN).",
|
||
pPrune: "Supprimer sur les cibles ce que le maître n'a pas", pPruneHelp: 'Désactivé par défaut. Activé : interfaces (jamais les interfaces physiques) et fichiers sysctl.d, chrony sources.d, interfaces.d absents du maître.',
|
||
pPolHelp: "sync : copie du maître ; ignore : jamais écrit ; override : écrit seulement depuis le mapping de la cible. Par défaut, les adresses et passerelles (cidr, cidr6, gateway, gateway6) sont en override : chaque nœud garde les siennes.",
|
||
pProtect: 'Lignes protégées des fichiers', pProtectHelp: "Expressions régulières, une par ligne : une ligne du maître qui correspond prend la valeur de la ligne de la cible qui correspond (supprimée si la cible n'en a pas).",
|
||
pMapping: 'Mapping par cible', pMappingHelp: "Interfaces : noms du maître et de la cible quand ils diffèrent (eno1=enp65s0f0, séparés par des virgules ; les VLAN eno1.100 suivent). Valeurs : une par ligne, « interface champ=valeur » (vmbr1 cidr=10.5.0.12/24) ou « dns champ=valeur ».",
|
||
mapNics: 'Interfaces (maître=cible)', mapValues: 'Valeurs propres',
|
||
confirmDeleteProfile: 'Supprimer le profil {p} ?', targetsN: '{n} cible(s)', discovered: '{n} nœud(s) trouvé(s)', clusterRoleN: 'cluster {r}',
|
||
netConfirm: "Ces changements rechargent le réseau de {c} (ifreload -a). S'il n'est plus joignable ensuite, le nœud restaure seul sa configuration précédente au bout de 120 s. Tapez « {w} » pour confirmer.",
|
||
netConfirmGlobal: "Ces changements rechargent le réseau de plusieurs nœuds, l'un après l'autre (arrêt au premier échec). Tapez le nom du profil « {w} » pour confirmer.",
|
||
netBadge: 'RÉSEAU', reloadsLine: 'Puis : {l}', fileSame: 'Fichier identique.', fileExtra: 'Fichier présent seulement sur la cible (conservé).',
|
||
nodeStats: 'Profil {p} · maître {ref} · {n} cible(s) · {d} avec écarts · lu à {t}',
|
||
noTargetN: 'Aucune cible dans ce profil.', noTargetNHint: 'Ajoutez des nœuds cibles au profil dans la vue Nœuds et profils.',
|
||
emptyOkHintN: 'La cible est conforme au maître pour les éléments du profil.',
|
||
syncAdminOnly: 'Seuls les administrateurs répliquent vers les nœuds.',
|
||
});
|
||
Object.assign(I18N.en, {
|
||
modeAriaSw: 'Replication kind', modeCluster: 'Clusters (API)', modeNode: 'Nodes (files)',
|
||
modeClusterTitle: 'Cluster to cluster replication through the API: datacenter options, accounts, SDN',
|
||
modeNodeTitle: 'Node to node replication: network and DNS (API), multipath, chrony, sysctl (SSH)',
|
||
viewNodes: 'Nodes and profiles', profileAria: 'Replication profile', nreference: 'MASTER', thMaster: 'Master',
|
||
nodesHelp: 'A profile replicates the system configuration of a master node to target nodes, of the same cluster or another one. The network and the DNS go through the PVE API, the files through SSH as root on each node (password of the cluster, or its own). The SSH host key of each node is recorded on the first test. The master is never written.',
|
||
addProfile: '+ New profile', discover: 'Discover the nodes', profilesTitle: 'Replication profiles', nodesTitle: 'Nodes',
|
||
noProfiles: 'No profile.', noNodes: 'No known node: "Discover the nodes" reads the nodes of every cluster (address in the cluster).',
|
||
setupNodes: 'No node replication profile: discover the nodes, then create a profile (master node, targets, items).', setupNodesBtn: 'Create a profile',
|
||
nodeHelp: 'SSH address of the node: empty, its address in the cluster. The root password is the one of the cluster, unless it has its own. A new address forgets the recorded host key.',
|
||
nHost: 'SSH address', nPassword: 'Root password', nClear: 'Use the password of the cluster', forgetKey: 'Forget the key',
|
||
editNode: 'Node {n}', nodePwdKeep: 'Empty: password unchanged ({w}).', pwdCluster: 'the cluster one', pwdOwn: 'own password',
|
||
newProfile: 'New profile', editProfile: 'Profile {p}', pMaster: 'Master node', pTargets: 'Target nodes', pItems: 'Replicated items',
|
||
pItemsHelp: 'Network and DNS: PVE API (interfaces.new then reload). The others: SSH as root, backup of the file on the node, atomic write, reload (sysctl --system, systemctl restart chrony, multipathd reconfigure, ifreload -a). wwids: add only. Never replicated: /etc/pve, corosync, hostname, SSH keys, interfaces.d/sdn (generated by the SDN).',
|
||
pPrune: 'Delete on the targets what the master does not have', pPruneHelp: 'Off by default. On: interfaces (never the physical ones) and the files of sysctl.d, chrony sources.d, interfaces.d absent from the master.',
|
||
pPolHelp: 'sync: copied from the master; ignore: never written; override: only written from the target mapping. By default the addresses and gateways (cidr, cidr6, gateway, gateway6) are override: each node keeps its own.',
|
||
pProtect: 'Protected lines of the files', pProtectHelp: 'Regular expressions, one per line: a line of the master that matches takes the value of the matching line of the target (dropped when the target has none).',
|
||
pMapping: 'Mapping per target', pMappingHelp: 'Interfaces: names on the master and on the target when they differ (eno1=enp65s0f0, comma separated; VLANs eno1.100 follow). Values: one per line, "interface field=value" (vmbr1 cidr=10.5.0.12/24) or "dns field=value".',
|
||
mapNics: 'Interfaces (master=target)', mapValues: 'Own values',
|
||
confirmDeleteProfile: 'Delete profile {p}?', targetsN: '{n} target(s)', discovered: '{n} node(s) found', clusterRoleN: 'cluster {r}',
|
||
netConfirm: 'These changes reload the network of {c} (ifreload -a). If it is unreachable afterwards, the node restores its previous configuration by itself after 120 s. Type "{w}" to confirm.',
|
||
netConfirmGlobal: 'These changes reload the network of several nodes, one after the other (stop at the first failure). Type the name of the profile "{w}" to confirm.',
|
||
netBadge: 'NETWORK', reloadsLine: 'Then: {l}', fileSame: 'Same file.', fileExtra: 'File only on the target (kept).',
|
||
nodeStats: 'Profile {p} · master {ref} · {n} target(s) · {d} with differences · read at {t}',
|
||
noTargetN: 'No target in this profile.', noTargetNHint: 'Add target nodes to the profile in the Nodes and profiles view.',
|
||
emptyOkHintN: 'The target is in line with the master for the items of the profile.',
|
||
syncAdminOnly: 'Only administrators replicate to the nodes.',
|
||
});
|
||
const NODE_KINDS = {
|
||
fr: [{ network: 'Réseau (API)', dns: 'DNS (API)', multipath: 'multipath.conf', wwids: 'wwids multipath', chrony: 'chrony', sysctl: 'sysctl.d', 'interfaces-d': 'interfaces.d' },
|
||
{ network: 'réseau', dns: 'dns', multipath: 'multipath', wwids: 'wwids', chrony: 'chrony', sysctl: 'sysctl', 'interfaces-d': 'if.d' }],
|
||
en: [{ network: 'Network (API)', dns: 'DNS (API)', multipath: 'multipath.conf', wwids: 'multipath wwids', chrony: 'chrony', sysctl: 'sysctl.d', 'interfaces-d': 'interfaces.d' },
|
||
{ network: 'network', dns: 'dns', multipath: 'multipath', wwids: 'wwids', chrony: 'chrony', sysctl: 'sysctl', 'interfaces-d': 'if.d' }],
|
||
};
|
||
for (const l of ['fr', 'en']) {
|
||
Object.assign(I18N[l].kinds, NODE_KINDS[l][0]);
|
||
Object.assign(I18N[l].kindShort, NODE_KINDS[l][1]);
|
||
}
|
||
Object.assign(I18N.fr.action, { 'nodes-discover': 'découverte des nœuds', 'node-save': 'nœud enregistré', 'node-check': 'test du nœud',
|
||
'node-forget': 'clé oubliée', 'profile-save': 'profil enregistré', 'profile-delete': 'profil supprimé', 'node-dry-run': 'dry-run nœuds',
|
||
'node-apply-start': 'réplication nœuds lancée', 'node-apply': 'réplication nœuds' });
|
||
Object.assign(I18N.en.action, { 'nodes-discover': 'node discovery', 'node-save': 'node saved', 'node-check': 'node test',
|
||
'node-forget': 'key forgotten', 'profile-save': 'profile saved', 'profile-delete': 'profile deleted', 'node-dry-run': 'node dry-run',
|
||
'node-apply-start': 'node replication started', 'node-apply': 'node replication' });
|
||
Object.assign(I18N.fr.scope, { nodes: 'nœuds', node: 'nœud', 'node-object': 'objet de nœud' });
|
||
Object.assign(I18N.en.scope, { nodes: 'nodes', node: 'node', 'node-object': 'node object' });
|
||
I18N.fr.res.skipped = 'non lancé';
|
||
I18N.en.res.skipped = 'not run';
|
||
const NITEMS = ['network', 'dns', 'multipath', 'wwids', 'chrony', 'sysctl', 'interfaces-d'];
|
||
|
||
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||
// kinds chosen as one item: the accounts of the pve realm with their passwords
|
||
const BUNDLES = { 'pve-user': ['pve-password'] };
|
||
const ITEMS = KINDS.filter((k) => !Object.values(BUNDLES).some((b) => b.includes(k)));
|
||
const bundled = (k) => [k, ...(BUNDLES[k] || [])];
|
||
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).replace(/^<hidden (\w+)>$/, (m, x) => '<' + t('redacted') + ' ' + x + '>');
|
||
}
|
||
|
||
// tm translates the messages written by the server (always in English) into
|
||
// the language of the page; unknown parts stay as they are.
|
||
const MSG_FR = [
|
||
[/^no difference$/, 'aucun écart'],
|
||
[/^(\d+) change\(s\) on (\d+) target\(s\), (\d+) target\(s\) in error$/, '$1 changement(s) sur $2 cible(s), $3 cible(s) en erreur'],
|
||
[/^(\d+) change\(s\) on (\d+) target\(s\)$/, '$1 changement(s) sur $2 cible(s)'],
|
||
[/^(\d+) change\(s\), verified$/, '$1 changement(s), vérifié'],
|
||
[/^(\d+) target\(s\) replicated and verified$/, '$1 cible(s) répliquée(s) et vérifiée(s)'],
|
||
[/^(\d+) target\(s\) failed, (\d+) replicated$/, '$1 cible(s) en échec, $2 répliquée(s)'],
|
||
[/^a replication is already running$/, 'une réplication est déjà en cours'],
|
||
[/: the state changed since the dry-run, run it again$/, " : l'état a changé depuis le dry-run, relancez-le"],
|
||
[/^SDN changes: confirmation required \((.*)\)$/, 'changements SDN : confirmation requise ($1)'],
|
||
[/^url: https:\/\/host:8006 expected$/, 'URL : https://hôte:8006 attendue'],
|
||
[/^user: root@pam expected.*$/, 'utilisateur : root@pam attendu (le même compte sert en SSH)'],
|
||
[/^ssh port: 1-65535 expected$/, 'port SSH : 1 à 65535'],
|
||
[/^select at least one kind$/, 'cochez au moins un élément'],
|
||
[/^name and url are required$/, 'nom et URL obligatoires'],
|
||
[/^the root password is required$/, 'le mot de passe root est obligatoire'],
|
||
[/^the CA certificate \(PEM\) is required$/, "le certificat d'autorité (PEM) est obligatoire"],
|
||
[/^cluster "(.*)" already exists$/, 'le cluster « $1 » existe déjà'],
|
||
[/^unknown cluster$/, 'cluster inconnu'],
|
||
[/^no primary cluster defined$/, 'aucun cluster primaire défini'],
|
||
[/^wrong current password$/, 'mot de passe actuel incorrect'],
|
||
[/^password managed by the identity provider$/, "mot de passe géré par le fournisseur d'identité"],
|
||
[/^invalid user name$/, "nom d'utilisateur invalide"],
|
||
[/^password: 8 characters minimum$/, 'mot de passe : 8 caractères minimum'],
|
||
[/^a password is required for a new user$/, 'un mot de passe est obligatoire pour un nouvel utilisateur'],
|
||
[/^this is the last local administrator$/, "c'est le dernier administrateur local"],
|
||
[/^role (\w+) required$/, 'rôle $1 requis'],
|
||
[/^TLS certificate not verified \(lab only\)$/, 'certificat TLS non vérifié (labo uniquement)'],
|
||
[/SSH: node (\S+) presents host key (\S+) instead of the recorded (\S+)/, "SSH : le nœud $1 présente la clé d'hôte $2 au lieu de $3 enregistrée"],
|
||
[/is not a PVE node/, "n'est pas un nœud PVE"],
|
||
[/node (\S+) runs PVE "([^"]*)", only (\d+)\.x is supported/, 'le nœud $1 est en PVE « $2 », seul $3.x est pris en charge'],
|
||
[/authentication of (\S+) failed/, "échec de l'authentification de $1"],
|
||
[/(\S+) has a second factor \(TFA\), not supported/, '$1 a un second facteur (TFA), non pris en charge'],
|
||
[/certificate fingerprint mismatch \(got ([^)]*)\)/, 'empreinte du certificat différente (reçue : $1)'],
|
||
[/host key not recorded yet, test the cluster first/, "clé d'hôte pas encore enregistrée, testez d'abord le cluster"],
|
||
[/: not found on the target$/, ' : absent de la cible'],
|
||
[/: field "([^"]*)" required for creation, add it to the mapping of target (.*)$/, ' : champ « $1 » obligatoire pour la création, à ajouter au mapping de la cible $2'],
|
||
[/: field "([^"]*)" differs \((.*)\) and cannot be changed, re-creation needed$/, ' : champ « $1 » différent ($2) et non modifiable, recréation nécessaire'],
|
||
[/: (\S+) absent on target (\S+) and not replicated$/, ' : $1 absent de la cible $2 et non répliqué'],
|
||
[/^zone (\S+): type "([^"]*)" not handled in v1, skipped$/, 'zone $1 : type « $2 » non pris en charge, ignorée'],
|
||
[/^vnet (\S+): zone "([^"]*)" not handled, skipped$/, 'vnet $1 : zone « $2 » non prise en charge, ignoré'],
|
||
[/plan has (\d+) error\(s\), nothing applied/, 'le plan a $1 erreur(s), rien appliqué'],
|
||
[/still different after apply: /, 'toujours différent après réplication : '],
|
||
[/SDN lock \(pending changes on the target\?\)/, 'verrou SDN (changements en attente sur la cible ?)'],
|
||
[/SDN configuration committed but reload failed/, 'configuration SDN validée mais rechargement en échec'],
|
||
[/root@pam cannot be deleted/, 'root@pam ne peut pas être supprimé'],
|
||
[/\bconnection refused\b/, 'connexion refusée'],
|
||
[/physical interface (\S+) absent on (\S+): add it to the NIC mapping of the target/, 'interface physique $1 absente de $2 : ajoutez-la au mapping des interfaces de la cible'],
|
||
[/type differs \((\S+) on the target, (\S+) on the master\): re-create the interface by hand/, 'type différent ($1 sur la cible, $2 sur le maître) : recréez l\'interface à la main'],
|
||
[/options the API does not write differ \((.*)\): not replicated/, "options que l'API n'écrit pas différentes ($1) : non répliquées"],
|
||
[/pending network changes on (\S+) \(interfaces.new\): apply or revert them in PVE first/, 'changements réseau en attente sur $1 (interfaces.new) : appliquez-les ou annulez-les d\'abord dans PVE'],
|
||
[/the master has pending network changes: its pending configuration is replicated/, 'le maître a des changements réseau en attente : sa configuration en attente est répliquée'],
|
||
[/a search domain is required by the API.*/, "un domaine de recherche est obligatoire pour l'API (PUT /nodes/{node}/dns)"],
|
||
[/^(\S+) absent on the master: not replicated$/, '$1 absent du maître : non répliqué'],
|
||
[/: (\d+) bytes, too large, not replicated/, ' : $1 octets, trop gros, non répliqué'],
|
||
[/SSH host key not recorded yet, test the node first/, "clé d'hôte SSH pas encore enregistrée, testez d'abord le nœud"],
|
||
[/node runs PVE "([^"]*)", only (\d+)\.x is supported/, 'le nœud est en PVE « $1 », seul $2.x est pris en charge'],
|
||
[/unknown node (\S+) \(discover the nodes of its cluster\)/, 'nœud $1 inconnu (découvrez les nœuds de son cluster)'],
|
||
[/changed since the dry-run: (\S+)/, 'modifié depuis le dry-run : $1'],
|
||
[/unreachable after the network reload: it restores its previous configuration by itself within (\S+)/, 'injoignable après le rechargement du réseau : il restaure seul sa configuration précédente sous $1'],
|
||
[/the node is unreachable: it restores its previous configuration by itself within (\S+)/, 'le nœud est injoignable : il restaure seul sa configuration précédente sous $1'],
|
||
[/^network changes: confirmation required \((.*)\)$/, 'changements réseau : confirmation requise ($1)'],
|
||
[/answers as "([^"]*)", not as node (\S+)/, 'répond en tant que « $1 », pas en tant que nœud $2'],
|
||
[/^select at least one item$/, 'cochez au moins un élément'],
|
||
[/^master node is required$/, 'le nœud maître est obligatoire'],
|
||
[/^name is required$/, 'le nom est obligatoire'],
|
||
[/target (\S+) is the master/, 'la cible $1 est le maître'],
|
||
[/^profile "(.*)" already exists$/, 'le profil « $1 » existe déjà'],
|
||
[/restore failed, run sh (\S+) on the node/, 'restauration en échec, lancez sh $1 sur le nœud'],
|
||
[/^unknown profile$/, 'profil inconnu'],
|
||
];
|
||
function tm(msg) {
|
||
if (lang !== 'fr' || !msg) return msg;
|
||
let out = String(msg);
|
||
for (const [re, to] of MSG_FR) out = out.replace(re, to);
|
||
return out;
|
||
}
|
||
|
||
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(tm(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 ----------
|
||
// the state of the differences view is kept per replication mode: S.state,
|
||
// S.client and S.skip are those of the current mode (getters below)
|
||
const MODES = {
|
||
cluster: { state: null, client: null, skip: new Set(JSON.parse(store.get('skipTargets') || '[]')), skipKey: 'skipTargets' },
|
||
node: { state: null, client: null, skip: new Set(JSON.parse(store.get('skipNodes') || '[]')), skipKey: 'skipNodes' },
|
||
};
|
||
const S = {
|
||
me: null,
|
||
mode: store.get('mode') === 'node' ? 'node' : 'cluster',
|
||
nodes: [], profiles: [], profile: store.get('profile') || '', // node replication
|
||
setup: '', // 'clusters' (empty database), 'primary' (no primary chosen) or 'nodes' (no profile)
|
||
clusters: [], // clusters view
|
||
settings: null, // synchronization settings (replicated kinds)
|
||
deps: {}, // kind -> kinds it needs
|
||
view: 'diff',
|
||
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(),
|
||
};
|
||
for (const k of ['state', 'client', 'skip']) {
|
||
Object.defineProperty(S, k, { get: () => MODES[S.mode][k], set: (v) => { MODES[S.mode][k] = v; } });
|
||
}
|
||
const isNode = () => S.mode === 'node';
|
||
const can = {
|
||
sync: () => S.me && (S.me.role === 'admin' || S.me.role === 'operator'),
|
||
admin: () => S.me && S.me.role === 'admin',
|
||
// writing system files as root on the nodes: administrators only
|
||
apply: () => (isNode() ? can.admin() : can.sync()),
|
||
};
|
||
|
||
// ---------- 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');
|
||
const kinds = isNode() ? NITEMS : KINDS;
|
||
if (!kinds.includes(S.kind)) S.kind = '';
|
||
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;
|
||
fillProfiles();
|
||
}
|
||
function fillProfiles() {
|
||
const ps = $('#profileSel');
|
||
ps.replaceChildren(...S.profiles.map((p) => h('option', { value: p.name, text: p.name })));
|
||
if (!S.profiles.some((p) => p.name === S.profile)) S.profile = S.profiles.length ? S.profiles[0].name : '';
|
||
ps.value = S.profile;
|
||
}
|
||
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/<target>, #clusters, #runs, #audit; node mode:
|
||
// #node/diff/<target>, #node/nodes, #node/runs) ----------
|
||
function readHash() {
|
||
const parts = decodeURIComponent(location.hash.slice(1)).split('/');
|
||
if (parts[0] === 'node') {
|
||
S.mode = 'node';
|
||
parts.shift();
|
||
} else if (parts[0]) {
|
||
S.mode = 'cluster';
|
||
}
|
||
const v = parts[0];
|
||
const views = isNode() ? ['diff', 'nodes', 'runs', 'audit'] : ['diff', 'clusters', 'runs', 'audit'];
|
||
S.view = views.includes(v) ? v : 'diff';
|
||
if (S.view === 'audit' && !can.admin()) S.view = 'diff';
|
||
// node keys hold a slash: cluster/node
|
||
const c = parts.slice(1).join('/');
|
||
if (c) S.client = c;
|
||
}
|
||
function writeHash() {
|
||
const hsh = '#' + (isNode() ? 'node/' : '') + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client).replace(/%2F/g, '/') : '');
|
||
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);
|
||
if (v === 'clusters') loadClusters();
|
||
if (v === 'nodes') loadNodes();
|
||
}
|
||
// setMode switches between the two replications; each keeps its own view state.
|
||
function setMode(m) {
|
||
if (m === S.mode) return;
|
||
S.mode = m;
|
||
store.set('mode', m === 'node' ? 'node' : null);
|
||
S.open.clear();
|
||
if (S.view === 'clusters' || S.view === 'nodes') S.view = m === 'node' ? 'nodes' : 'clusters';
|
||
fillSelects();
|
||
writeHash();
|
||
renderAll();
|
||
if (S.view === 'nodes') loadNodes();
|
||
if (S.view === 'clusters') loadClusters();
|
||
if (S.view === 'runs') renderRuns();
|
||
if (!S.state) loadState(false);
|
||
}
|
||
|
||
// ---------- loading ----------
|
||
async function loadState(refresh) {
|
||
if (isNode()) return loadNodeState(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();
|
||
}
|
||
// loadNodeState reads the profiles, then the comparison of the selected one.
|
||
async function loadNodeState(refresh) {
|
||
const mode = MODES.node;
|
||
busy(true);
|
||
$('#refreshBtn').classList.add('spin');
|
||
try {
|
||
const d = await api('GET', 'api/nodes');
|
||
S.nodes = d.nodes;
|
||
S.profiles = d.profiles;
|
||
S.nodeItems = d.items;
|
||
S.nodeDefaults = d.defaults || {};
|
||
fillProfiles();
|
||
$('#error').hidden = true;
|
||
if (!S.profile) {
|
||
mode.state = null;
|
||
} else {
|
||
const r = await api('GET', 'api/nodestate?profile=' + encodeURIComponent(S.profile) + (refresh ? '&refresh=1' : ''));
|
||
mode.state = r.state;
|
||
const names = r.state.clients.map((c) => c.name);
|
||
if (!names.includes(mode.client)) {
|
||
const first = r.state.clients.find((c) => c.error || (c.plan && ((c.plan.changes || []).length || (c.plan.errors || []).length)));
|
||
mode.client = first ? first.name : names[0] || null;
|
||
}
|
||
}
|
||
if (isNode()) writeHash();
|
||
} catch (e) {
|
||
if (e.message !== 'auth') showError(e.message);
|
||
} finally {
|
||
busy(false);
|
||
$('#refreshBtn').classList.remove('spin');
|
||
}
|
||
if (isNode()) renderAll();
|
||
}
|
||
function showError(msg) {
|
||
const el = $('#error');
|
||
el.textContent = msg;
|
||
el.hidden = !msg;
|
||
}
|
||
|
||
// ---------- rendering: differences ----------
|
||
function renderAll() {
|
||
renderView();
|
||
renderStatus();
|
||
}
|
||
function renderView() {
|
||
document.body.classList.toggle('mode-node', isNode());
|
||
$$('#modeSw [data-mode]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.mode === S.mode)));
|
||
$('#viewMode [data-view="clusters"]').hidden = isNode();
|
||
$('#viewMode [data-view="nodes"]').hidden = !isNode();
|
||
$$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view)));
|
||
$('#diffView').hidden = S.view !== 'diff';
|
||
$('#clustersView').hidden = S.view !== 'clusters';
|
||
$('#nodesView').hidden = S.view !== 'nodes';
|
||
$('#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'; });
|
||
$$('[data-node-only]').forEach((e) => { e.hidden = !isNode() || (e.matches('[data-diff]') && S.view !== 'diff') || (e.id === 'profileSel' && !S.profiles.length); });
|
||
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
|
||
const setup = isNode() ? (S.profiles.length || !S.me ? '' : 'nodes') : S.setup;
|
||
$('#setup').hidden = !setup || S.view === 'clusters' || S.view === 'nodes';
|
||
if (setup) {
|
||
const k = { clusters: ['setupClusters', 'setupClustersBtn'], primary: ['setupPrimary', 'setupPrimaryBtn'], nodes: ['setupNodes', 'setupNodesBtn'] }[setup];
|
||
$('#setupText').textContent = t(k[0]);
|
||
$('#setupBtn').textContent = t(k[1]);
|
||
}
|
||
$$('[data-admin-only]').forEach((e) => { e.hidden = !can.admin(); });
|
||
if (S.view === 'diff') { renderDiff(); renderTargetMenu(); }
|
||
if (S.view === 'clusters') renderClusters();
|
||
if (S.view === 'nodes') renderNodes();
|
||
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' },
|
||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t(isNode() ? 'nreference' : 'reference') }),
|
||
r.error ? h('span', { class: 'c-role st st-error', text: t('cst.error') }) : null, h('span', { class: 'c-name', text: r.node || r.name, title: r.name }),
|
||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||
h('div', { class: 'c-url', text: r.url || (r.cluster ? 'cluster ' + r.cluster : '') }),
|
||
r.error ? h('div', { class: 'c-err', text: tm(r.error), title: r.error })
|
||
: h('div', { class: 'c-counts' }, r.objects != null ? h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }) : null,
|
||
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 || {};
|
||
for (const [k, sym] of [['create', '+'], ['update', '~'], ['delete', '−'], ['error', '!'], ['extra', '?']]) {
|
||
if (n[k]) { counts.appendChild(h('span', { class: 'cnt ' + k, title: t('st.' + k), text: sym + n[k] })); }
|
||
}
|
||
}
|
||
strip.appendChild(h('button', {
|
||
type: 'button', role: 'tab', class: 'ccard', '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-role st st-' + cs, text: t('cst.' + cs) }), h('span', { class: 'c-name', text: c.node || c.name, title: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||
h('div', { class: 'c-url', text: c.url || (c.cluster ? 'cluster ' + c.cluster : '') }),
|
||
counts,
|
||
c.error ? h('div', { class: 'c-err', text: tm(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(isNode() ? 'noTargetN' : 'noClient'), t(isNode() ? 'noTargetNHint' : '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 || !can.apply(), title: can.apply() ? null : t('syncAdminOnly'),
|
||
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'), issue(e)));
|
||
for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), issue(w)));
|
||
for (const w of c.warnings || []) if (isNode()) 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(isNode() ? 'emptyOkHintN' : '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;
|
||
}
|
||
// issue: the plans of the node replication give {key, message}, the cluster ones strings
|
||
function issue(e) {
|
||
if (e == null || typeof e === 'string') return e;
|
||
return (e.key ? e.key.replace(/^[^/]+\//, '') + ' : ' : '') + e.message;
|
||
}
|
||
function note(kind, label, text) {
|
||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text: tm(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)]),
|
||
...(r.diff || []).map((l) => l.text)].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 = tm((r.errors || [])[0] || ''); s.title = s.textContent; return s; }
|
||
if (r.diff && r.diff.length) {
|
||
const add = r.diff.filter((l) => l.op === '+').length;
|
||
const del = r.diff.filter((l) => l.op === '-').length;
|
||
if (add) s.appendChild(h('span', { class: 'f-add', text: '+' + add }));
|
||
if (add && del) s.appendChild(document.createTextNode(' '));
|
||
if (del) s.appendChild(h('span', { class: 'f-unset', text: '−' + del }));
|
||
return s;
|
||
}
|
||
if (!(r.fields || []).length && isNode()) { s.textContent = t(r.status === 'extra' ? 'fileExtra' : 'fileSame'); 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] })),
|
||
can.apply() ? iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go') : null,
|
||
] : 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;
|
||
}
|
||
|
||
// udiff renders the unified diff of a file
|
||
function udiff(lines) {
|
||
return h('div', { class: 'udiff' }, ...lines.map((l) => h('div', {
|
||
class: l.op === '+' ? 'd-add' : l.op === '-' ? 'd-del' : l.op === '@' ? 'd-hunk' : '',
|
||
text: l.op === '@' ? l.text : l.op + l.text,
|
||
})));
|
||
}
|
||
function details(r) {
|
||
const d = h('div', { class: 'details' });
|
||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.map(tm).join('\n') }));
|
||
if (r.diff && r.diff.length) {
|
||
d.appendChild(udiff(r.diff));
|
||
return d;
|
||
}
|
||
if (isNode() && !(r.fields || []).length) {
|
||
d.appendChild(h('p', { class: 'muted', text: t(r.status === 'extra' ? 'fileExtra' : 'fileSame') }));
|
||
return d;
|
||
}
|
||
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', isNode() ? 'thMaster' : '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 = isNode() ? t('nodeStats', { p: st.profile, ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) })
|
||
: 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, node: false };
|
||
|
||
function whatLabel(sel) {
|
||
if (sel.scope === 'global') return (sel.targets || []).length ? t('whatTargets', { l: sel.targets.join(', ') }) : 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) {
|
||
// a plan stays in the mode it was opened in (node: profile and endpoints)
|
||
if (sel.profile === undefined && isNode()) sel = Object.assign({ profile: S.profile }, sel);
|
||
plan.node = sel.profile !== undefined;
|
||
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', plan.node ? 'api/nodedryrun' : 'api/dryrun', sel);
|
||
plan.plans = d.plans;
|
||
renderPlanKinds(sel, d.plans, intentSync);
|
||
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 = !(plan.node ? can.admin() : 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);
|
||
}
|
||
}
|
||
|
||
// renderPlanKinds shows, above the plan, one "replicate / do not replicate"
|
||
// choice per item: it is the saved list of replicated items (administrators),
|
||
// and a change runs the dry-run again, so the reviewed plan is the one applied.
|
||
function renderPlanKinds(sel, plans, intentSync) {
|
||
const box = $('#planKinds');
|
||
box.hidden = sel.scope === 'object' || !S.settings || plan.node;
|
||
if (box.hidden) return;
|
||
const kinds = S.settings.kinds || KINDS;
|
||
const admin = can.admin();
|
||
const count = (k) => plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).filter((c) => bundled(k).includes(c.kind)).length, 0);
|
||
const rows = ITEMS.map((k) => {
|
||
const off = !kinds.includes(k);
|
||
const set = async (v) => {
|
||
if (await saveKinds(withDeps(kinds, k, v))) openPlan(sel, intentSync);
|
||
};
|
||
const n = off ? '—' : String(count(k));
|
||
return h('div', { class: 'pk-row' + (off ? ' off' : '') },
|
||
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
||
h('span', { class: 'pk-name', text: t('kinds.' + k) }),
|
||
h('span', { class: 'pk-n', text: n, title: off ? '' : t('changesN', { n }) }),
|
||
h('div', { class: 'seg', role: 'radiogroup', 'aria-label': t('kinds.' + k) },
|
||
h('button', { type: 'button', role: 'radio', class: off ? '' : 'on', 'aria-checked': String(!off), disabled: !admin, onclick: () => off && set(true) }, t('kindOn')),
|
||
h('button', { type: 'button', role: 'radio', class: off ? 'on' : '', 'aria-checked': String(off), disabled: !admin, onclick: () => !off && set(false) }, t('kindOff'))));
|
||
});
|
||
box.replaceChildren(h('div', { class: 'pk-head' }, h('strong', { text: t('planKinds') }), h('span', { class: 'muted small', text: t(admin ? 'planKindsHelp' : 'planKindsRO') })), ...rows);
|
||
}
|
||
// saveKinds saves the list of replicated items (at least one).
|
||
async function saveKinds(kinds) {
|
||
if (!kinds.length) return false;
|
||
const body = Object.assign({}, S.settings, { kinds });
|
||
try {
|
||
await api('PUT', 'api/settings', body);
|
||
S.settings = body;
|
||
return true;
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
return 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' && !ch.diff) 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);
|
||
if (ch.diff && ch.diff.length) li.appendChild(udiff(ch.diff));
|
||
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.network ? h('span', { class: 'badge network', text: t('netBadge') }) : 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', '', issue(e)))) : null,
|
||
((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), issue(w)))) : null,
|
||
changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null),
|
||
changes.length && (p.reloads || []).length ? h('p', { class: 'plan reloads', text: t('reloadsLine', { l: p.reloads.join(' ; ') }) }) : null));
|
||
}
|
||
}
|
||
|
||
async function applyPlan() {
|
||
const sel = plan.sel;
|
||
const plans = plan.plans || [];
|
||
const sdn = plans.some((p) => (p.sdn || p.network) && ((p.plan && p.plan.changes) || []).length);
|
||
const want = sel.scope === 'global' ? (plan.node ? sel.profile : MODES.cluster.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: plan.node ? (sel.scope === 'global' ? t('netConfirmGlobal', { w: want }) : t('netConfirm', { c: sel.client, w: want }))
|
||
: 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', plan.node ? 'api/nodeapply' : 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||
if (!d.run) { toast(tm(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 }) + ' · ' + tm(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,
|
||
p.network ? h('span', { class: 'badge network', text: t('netBadge') }) : null));
|
||
if (p.error) box.appendChild(note('err', t('clientError'), p.error));
|
||
for (const e of errs) box.appendChild(note('err', t('planError'), issue(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: tm(s.error) }) : null))));
|
||
}
|
||
if (res && res.error) box.appendChild(note('err', t('st.error'), res.error));
|
||
if (res && res.status === 'skipped') box.appendChild(h('p', { class: 'muted small', text: t('res.skipped') }));
|
||
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) => (r.scope || '').startsWith('node') === isNode())
|
||
.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: tm(r.summary), title: tm(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: 'sync', 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' && !can.admin()) tab = 'sync';
|
||
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 === 'sync') loadSettings();
|
||
if (tab === 'users') loadUsers();
|
||
if (tab === 'account') renderAccount();
|
||
if (tab === 'appearance') {
|
||
setTheme(themeOpt());
|
||
setDensity(store.get('density') || 'normal');
|
||
applyLang();
|
||
}
|
||
}
|
||
|
||
// ---------- clusters view ----------
|
||
async function loadClusters() {
|
||
try {
|
||
S.clusters = (await api('GET', 'api/clusters')).clusters;
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
return;
|
||
}
|
||
renderClusters();
|
||
}
|
||
function checkLine(c) {
|
||
const k = c.lastCheck;
|
||
if (!k) return h('span', { class: 'muted', text: t('neverTested') });
|
||
const parts = [t('testedAt', { t: fmtTime(k.at, true) })];
|
||
if (k.version) parts.push('PVE ' + k.version);
|
||
if ((k.nodes || []).length) parts.push(t('nodes', { n: k.nodes.length }) + ' (' + k.nodes.join(', ') + ')');
|
||
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + tm(k.error) : '') });
|
||
}
|
||
function renderClusters() {
|
||
const list = $('#clusterList');
|
||
list.replaceChildren();
|
||
if (!S.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupClusters') }));
|
||
for (const c of S.clusters) {
|
||
const chk = h('div', { class: 'chk', hidden: true });
|
||
const admin = can.admin();
|
||
let role = null;
|
||
if (admin) {
|
||
role = h('select', { class: 'field', 'aria-label': t('cRole'), onchange: () => setRole(c.name, role.value) },
|
||
...['primary', 'target', 'disabled'].map((r) => h('option', { value: r, text: t('crole.' + r) })));
|
||
role.value = c.role;
|
||
}
|
||
const meta = h('div', { class: 'meta' });
|
||
meta.appendChild(checkLine(c));
|
||
if (admin && c.tls === 'fp' && c.fingerprint) meta.appendChild(h('span', { class: 'mono', title: c.fingerprint, text: 'TLS ' + c.fingerprint.slice(0, 23) + '…' }));
|
||
if (admin && c.tls && c.tls !== 'fp') meta.appendChild(h('span', { text: 'TLS : ' + t({ ca: 'tlsCa', sys: 'tlsSys', insecure: 'tlsInsecure' }[c.tls]) }));
|
||
if (admin && c.sshFingerprint) meta.appendChild(h('span', { class: 'mono', title: c.sshFingerprint, text: 'SSH ' + c.sshFingerprint }));
|
||
list.appendChild(h('div', { class: 'citem cl ' + c.role },
|
||
h('div', { class: 'n' }, h('span', { class: 'rbadge ' + c.role, text: t('crole.' + c.role) }), h('span', { text: c.name })),
|
||
h('div', { class: 'u', text: c.url + (c.sshHost || (c.sshPort && c.sshPort !== 22) ? ' · ssh ' + (c.sshHost || '') + (c.sshPort && c.sshPort !== 22 ? ':' + c.sshPort : '') : ''), title: c.url }),
|
||
admin ? h('div', { class: 'acts' }, role,
|
||
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'),
|
||
meta, chk));
|
||
}
|
||
}
|
||
async function setRole(name, role) {
|
||
try {
|
||
await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/role', { role });
|
||
toast(t('saved'));
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
}
|
||
await loadClusters();
|
||
loadState(true);
|
||
}
|
||
// checkCluster tests the API and SSH; the first test records the certificate
|
||
// and the host key. A changed host key is only accepted on request (trust).
|
||
async function checkCluster(name, el, trust) {
|
||
if (el) {
|
||
el.hidden = false;
|
||
el.className = 'chk muted';
|
||
el.textContent = t('testing');
|
||
}
|
||
let r;
|
||
try {
|
||
r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check', trust ? { trust: true } : undefined);
|
||
} catch (e) {
|
||
if (el) { el.className = 'chk ko'; el.textContent = '✗ ' + e.message; }
|
||
return false;
|
||
}
|
||
await loadClusters();
|
||
// the list was redrawn: show the result under the new row
|
||
const row = $$('#clusterList .citem').find((x) => $('.n span:last-child', x).textContent === name);
|
||
el = row ? $('.chk', row) : null;
|
||
if (!el) return r.ok;
|
||
el.hidden = false;
|
||
const pins = [];
|
||
if (r.tlsFingerprint) pins.push('TLS ' + r.tlsFingerprint);
|
||
if (r.sshFingerprint && !r.sshMismatch) pins.push('SSH ' + r.sshFingerprint);
|
||
if (!r.ok) {
|
||
el.className = 'chk ko';
|
||
el.replaceChildren('✗ ' + tm(r.error));
|
||
if (r.sshMismatch) {
|
||
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
||
onclick: () => { if (confirm(t('confirmTrust', { c: name, fp: r.sshFingerprint }))) checkCluster(name, el, true); } }));
|
||
}
|
||
return false;
|
||
}
|
||
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
|
||
el.textContent = '✓ ' + t('testOk') + (pins.length ? ' — ' + t('pinned', { l: pins.join(' · ') }) : '') +
|
||
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.map(tm).join(' ; ') : '');
|
||
return true;
|
||
}
|
||
async function testAll() {
|
||
for (const c of S.clusters) {
|
||
const row = $$('#clusterList .citem').find((x) => $('.n span:last-child', x).textContent === c.name);
|
||
await checkCluster(c.name, row ? $('.chk', row) : null);
|
||
}
|
||
loadState(true);
|
||
}
|
||
|
||
let editing = null;
|
||
function editCluster(c) {
|
||
editing = c || null;
|
||
const isNew = !c;
|
||
c = c || { name: '', role: S.clusters.some((x) => x.role === 'primary') ? 'target' : 'primary', url: '', tls: 'fp', fingerprint: '', caPem: '',
|
||
timeout: '', sshHost: '', sshPort: 22, mapping: {}, policies: {} };
|
||
$('#clusterTitle').textContent = isNew ? t('newCluster') : t('editCluster', { c: c.name });
|
||
$('#cName').value = c.name;
|
||
setSeg('#cRole', c.role);
|
||
$('#cURL').value = c.url;
|
||
$('#cPassword').value = '';
|
||
$('#cPassword').required = isNew || !c.hasPassword;
|
||
$('#cPasswordHint').textContent = isNew || !c.hasPassword ? t('passwordNew') : t('passwordKeep');
|
||
$('#cTLS').value = c.tls || 'fp';
|
||
$('#cFp').value = c.fingerprint || '';
|
||
$('#cCA').value = c.caPem || '';
|
||
$('#cSSHHost').value = c.sshHost || '';
|
||
$('#cSSHPort').value = c.sshPort && c.sshPort !== 22 ? c.sshPort : '';
|
||
$('#cSSHKey').textContent = c.sshFingerprint || t('sshKeyNone');
|
||
$('#cTimeout').value = c.timeout || '';
|
||
$('#cDelete').hidden = isNew;
|
||
$('#cForget').hidden = isNew || (!c.sshHostKey && !(c.tls === 'fp' && c.fingerprint));
|
||
$('#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') === 'primary'; }
|
||
|
||
function kindSelect(v) {
|
||
const s = h('select', { class: 'field' }, ...KINDS.map((k) => h('option', { value: k, text: t('kindShort.' + k) })));
|
||
s.value = v || 'sdn-zone';
|
||
return s;
|
||
}
|
||
function refIds(kind) {
|
||
const st = MODES.cluster.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 = MODES.cluster.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(),
|
||
role: segVal('#cRole') || 'target',
|
||
position: editing ? editing.position : S.clusters.length,
|
||
url: $('#cURL').value.trim(),
|
||
user: 'root@pam',
|
||
password: $('#cPassword').value,
|
||
hasPassword: false,
|
||
tls,
|
||
fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '',
|
||
caPem: tls === 'ca' ? $('#cCA').value.trim() : '',
|
||
timeout: $('#cTimeout').value.trim(),
|
||
sshHost: $('#cSSHHost').value.trim(),
|
||
sshPort: Number($('#cSSHPort').value) || 22,
|
||
sshHostKey: '',
|
||
sshFingerprint: '',
|
||
mapping: {},
|
||
policies: {},
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
if (c.role !== 'primary') {
|
||
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.password) delete c.password;
|
||
try {
|
||
await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c);
|
||
$('#clusterDlg').close();
|
||
toast(t('saved'));
|
||
await loadClusters();
|
||
// test at once: records the certificate and the host key of a new cluster
|
||
await checkCluster(c.name, null);
|
||
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;
|
||
}
|
||
}
|
||
async function forgetPins() {
|
||
if (!editing || !confirm(t('confirmForget', { c: editing.name }))) return;
|
||
try {
|
||
await api('POST', 'api/clusters/' + encodeURIComponent(editing.name) + '/forget');
|
||
$('#clusterDlg').close();
|
||
loadClusters();
|
||
} catch (e) {
|
||
$('#cErr').textContent = e.message;
|
||
$('#cErr').hidden = false;
|
||
}
|
||
}
|
||
|
||
// several clusters at once: "name URL" or "URL" per line, same root password
|
||
function openBulk() {
|
||
$('#bulkRes').replaceChildren();
|
||
$('#bulkGo').disabled = false;
|
||
$('#bulkDlg').showModal();
|
||
$('#bulkText').focus();
|
||
}
|
||
async function addBulk(ev) {
|
||
ev.preventDefault();
|
||
const res = $('#bulkRes');
|
||
res.replaceChildren();
|
||
const line = (cls, text) => res.appendChild(h('li', { class: cls, text }));
|
||
const items = [];
|
||
for (const raw of $('#bulkText').value.split('\n')) {
|
||
const l = raw.trim();
|
||
if (!l || l.startsWith('#')) continue;
|
||
const p = l.split(/\s+/);
|
||
let name = p.length > 1 ? p[0] : '';
|
||
let url = p.length > 1 ? p[1] : p[0];
|
||
if (!/^https:\/\//.test(url)) url = 'https://' + url;
|
||
try {
|
||
const u = new URL(url);
|
||
if (!u.port) u.port = '8006';
|
||
url = u.origin;
|
||
name = name || u.hostname;
|
||
} catch (e) {
|
||
line('ko', t('bulkBadLine', { l }));
|
||
continue;
|
||
}
|
||
items.push({ name, url });
|
||
}
|
||
$('#bulkGo').disabled = true;
|
||
let pos = S.clusters.length;
|
||
for (const it of items) {
|
||
try {
|
||
await api('PUT', 'api/clusters/-', { name: it.name, role: 'target', position: pos++, url: it.url, user: 'root@pam',
|
||
password: $('#bulkPassword').value, hasPassword: false, tls: $('#bulkTLS').value, fingerprint: '', caPem: '', timeout: '',
|
||
sshHost: '', sshPort: 22, sshHostKey: '', sshFingerprint: '', mapping: {}, policies: {}, updatedAt: new Date().toISOString() });
|
||
const r = await api('POST', 'api/clusters/' + encodeURIComponent(it.name) + '/check');
|
||
line(r.ok ? 'ok' : 'ko', (r.ok ? '✓ ' : '✗ ') + it.name + ' — ' + (r.ok ? 'PVE ' + r.version + (r.sshFingerprint ? ' · SSH ' + r.sshFingerprint : '') : tm(r.error)));
|
||
} catch (e) {
|
||
if (e.message === 'auth') return;
|
||
line('ko', '✗ ' + it.name + ' — ' + e.message);
|
||
}
|
||
}
|
||
$('#bulkGo').disabled = false;
|
||
loadClusters();
|
||
loadState(true);
|
||
}
|
||
|
||
// synchronization settings (Save / Cancel like Logstream)
|
||
async function loadSettings() {
|
||
try {
|
||
const d = await api('GET', 'api/settings');
|
||
set.settings = d.settings;
|
||
S.settings = d.settings;
|
||
S.deps = d.deps || {};
|
||
} 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(...ITEMS.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) => {
|
||
const on = withDeps($$('#kindChips .chip.on').map((c) => c.dataset.kind), k, !ev.currentTarget.classList.contains('on'));
|
||
$$('#kindChips .chip').forEach((c) => { c.classList.toggle('on', on.includes(c.dataset.kind)); c.setAttribute('aria-pressed', String(on.includes(c.dataset.kind))); });
|
||
setDirty(true);
|
||
},
|
||
}, t('kinds.' + k))));
|
||
$('#pruneSw').checked = !!st.prune;
|
||
const sel = $('#selRows');
|
||
sel.replaceChildren(...ITEMS.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(...ITEMS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), t('polDef.' + 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 };
|
||
}
|
||
// a password follows the selection of its account
|
||
for (const [k, b] of Object.entries(BUNDLES)) for (const x of b) { if (objects[k]) objects[x] = objects[k]; }
|
||
const body = {
|
||
kinds: KINDS.filter((k) => $$('#kindChips .chip.on').some((c) => bundled(c.dataset.kind).includes(k))),
|
||
objects,
|
||
policies: readPolicies($('#polRows')),
|
||
prune: $('#pruneSw').checked,
|
||
};
|
||
try {
|
||
await api('PUT', 'api/settings', body);
|
||
setDirty(false);
|
||
$('#setState').textContent = t('saved');
|
||
S.settings = Object.assign({}, S.settings, body);
|
||
loadState(true);
|
||
} catch (e) {
|
||
$('#setState').textContent = t('saveFailed', { e: e.message });
|
||
}
|
||
}
|
||
|
||
// ---------- replicated items, targets of the global actions ----------
|
||
// withDeps checks or unchecks a kind with the kinds it needs (or that need it).
|
||
function withDeps(kinds, k, on) {
|
||
const out = new Set(kinds);
|
||
const need = (x) => { out.add(x); bundled(x).forEach((b) => out.add(b)); (S.deps[x] || []).forEach(need); };
|
||
const drop = (x) => { out.delete(x); Object.entries(S.deps).forEach(([y, ds]) => { if (ds.includes(x) && out.has(y)) drop(y); }); };
|
||
if (on) need(k); else bundled(k).forEach(drop);
|
||
return KINDS.filter((x) => out.has(x));
|
||
}
|
||
// targets of the global dry-run / replication (all by default; the ones
|
||
// unchecked are remembered in this browser)
|
||
function selectedTargets() {
|
||
return S.state ? S.state.clients.map((c) => c.name).filter((n) => !S.skip.has(n)) : [];
|
||
}
|
||
function renderTargetMenu() {
|
||
const pop = $('#targetMenuPop');
|
||
const names = S.state ? S.state.clients.map((c) => c.name) : [];
|
||
const sel = selectedTargets();
|
||
$('#targetMenuN').textContent = sel.length + '/' + names.length;
|
||
$('#dryAllBtn').disabled = $('#syncAllBtn').disabled = sel.length === 0;
|
||
const all = h('input', { type: 'checkbox', checked: sel.length === names.length,
|
||
onchange: (ev) => { S.skip = ev.target.checked ? new Set() : new Set(names); saveSkip(); } });
|
||
pop.replaceChildren(h('label', { class: 'opt' }, all, h('strong', { text: t('allTargets') })));
|
||
for (const c of S.state ? S.state.clients : []) {
|
||
const st = clientStatus(c);
|
||
pop.appendChild(h('label', { class: 'opt' },
|
||
h('input', { type: 'checkbox', checked: !S.skip.has(c.name),
|
||
onchange: (ev) => { if (ev.target.checked) S.skip.delete(c.name); else S.skip.add(c.name); saveSkip(); } }),
|
||
h('span', { text: c.name }),
|
||
h('span', { class: 'h', text: st === 'ok' ? '✓' : st === 'error' ? '✗' : '~' })));
|
||
}
|
||
}
|
||
function saveSkip() {
|
||
store.set(MODES[S.mode].skipKey, S.skip.size ? JSON.stringify(Array.from(S.skip)) : null);
|
||
renderTargetMenu();
|
||
}
|
||
function globalSel() {
|
||
const names = S.state ? S.state.clients.map((c) => c.name) : [];
|
||
const sel = selectedTargets();
|
||
const g = { scope: 'global', targets: sel.length === names.length ? [] : sel };
|
||
if (isNode()) g.profile = S.profile;
|
||
return g;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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); }
|
||
}
|
||
|
||
// ---------- nodes and profiles view (node replication) ----------
|
||
async function loadNodes() {
|
||
try {
|
||
const d = await api('GET', 'api/nodes');
|
||
S.nodes = d.nodes;
|
||
S.profiles = d.profiles;
|
||
S.nodeItems = d.items;
|
||
S.nodeDefaults = d.defaults || {};
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
return;
|
||
}
|
||
fillProfiles();
|
||
renderView();
|
||
}
|
||
function renderNodes() {
|
||
const admin = can.admin();
|
||
const pl = $('#profileList');
|
||
pl.replaceChildren();
|
||
if (!S.profiles.length) pl.appendChild(h('p', { class: 'muted', text: t('noProfiles') }));
|
||
for (const p of S.profiles) {
|
||
pl.appendChild(h('div', { class: 'citem prof' + (p.name === S.profile ? ' ref' : '') },
|
||
h('div', { class: 'n' }, h('span', { text: p.name })),
|
||
h('div', { class: 'u', text: p.master + ' → ' + ((p.targets || []).join(', ') || '—'), title: (p.targets || []).join(', ') }),
|
||
h('div', { class: 'acts' },
|
||
h('button', { type: 'button', class: 'btn tool', onclick: () => { S.profile = p.name; store.set('profile', p.name); MODES.node.state = null; setView('diff'); loadState(false); } },
|
||
icon('scan'), t('viewDiff')),
|
||
admin ? h('button', { type: 'button', class: 'btn tool', onclick: () => editProfile(p) }, icon('pencil'), t('edit')) : null),
|
||
h('div', { class: 'items' }, ...(p.items || []).map((k) => h('span', { class: 'kind ' + k, text: t('kinds.' + k) })),
|
||
h('span', { class: 'muted small', text: t('targetsN', { n: (p.targets || []).length }) + (p.prune ? ' · prune' : '') }))));
|
||
}
|
||
const nl = $('#nodeList');
|
||
nl.replaceChildren();
|
||
if (!S.nodes.length) nl.appendChild(h('p', { class: 'muted', text: t('noNodes') }));
|
||
for (const n of S.nodes) {
|
||
const chk = h('div', { class: 'chk', hidden: true });
|
||
const meta = h('div', { class: 'meta' }, checkLine(n));
|
||
if (admin && n.sshFingerprint) meta.appendChild(h('span', { class: 'mono', title: n.sshFingerprint, text: 'SSH ' + n.sshFingerprint }));
|
||
if (admin && n.hasPassword) meta.appendChild(h('span', { text: t('pwdOwn') }));
|
||
const addr = admin ? 'ssh root@' + (n.sshHost || n.ip) + (n.sshPort && n.sshPort !== 22 ? ':' + n.sshPort : '') : '';
|
||
nl.appendChild(h('div', { class: 'citem node cl ' + (n.clusterRole || '') },
|
||
h('div', { class: 'n' }, h('span', { class: 'rbadge ' + n.clusterRole, text: n.cluster }), h('span', { text: n.node })),
|
||
h('div', { class: 'u', text: addr, title: addr }),
|
||
admin ? h('div', { class: 'acts' },
|
||
h('button', { type: 'button', class: 'btn tool', onclick: () => checkNode(n, chk) }, icon('plug'), t('test')),
|
||
h('button', { type: 'button', class: 'btn tool', onclick: () => editNode(n) }, icon('pencil'), t('edit'))) : h('span'),
|
||
meta, chk));
|
||
}
|
||
}
|
||
const nodePath = (n) => 'api/nodes/' + encodeURIComponent(n.cluster) + '/' + encodeURIComponent(n.node);
|
||
async function checkNode(n, el, trust) {
|
||
if (el) { el.hidden = false; el.className = 'chk muted'; el.textContent = t('testing'); }
|
||
let r;
|
||
try {
|
||
r = await api('POST', nodePath(n) + '/check', trust ? { trust: true } : undefined);
|
||
} catch (e) {
|
||
if (el) { el.className = 'chk ko'; el.textContent = '✗ ' + e.message; }
|
||
return false;
|
||
}
|
||
await loadNodes();
|
||
const row = $$('#nodeList .citem').find((x) => $('.n span:first-child', x).textContent === n.cluster && $('.n span:last-child', x).textContent === n.node);
|
||
el = row ? $('.chk', row) : null;
|
||
if (!el) return r.ok;
|
||
el.hidden = false;
|
||
if (!r.ok) {
|
||
el.className = 'chk ko';
|
||
el.replaceChildren('✗ ' + tm(r.error));
|
||
if (r.sshMismatch) {
|
||
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
||
onclick: () => { if (confirm(t('confirmTrust', { c: n.key, fp: r.sshFingerprint }))) checkNode(n, el, true); } }));
|
||
}
|
||
return false;
|
||
}
|
||
el.className = 'chk ok';
|
||
el.textContent = '✓ ' + t('testOk') + (r.version ? ' · PVE ' + r.version : '') + (r.sshFingerprint ? ' — ' + t('pinned', { l: 'SSH ' + r.sshFingerprint }) : '');
|
||
return true;
|
||
}
|
||
async function testNodes() {
|
||
for (const n of S.nodes) {
|
||
const row = $$('#nodeList .citem').find((x) => $('.n span:first-child', x).textContent === n.cluster && $('.n span:last-child', x).textContent === n.node);
|
||
await checkNode(n, row ? $('.chk', row) : null);
|
||
}
|
||
}
|
||
async function discoverNodes() {
|
||
busy(true);
|
||
try {
|
||
const d = await api('POST', 'api/nodes/discover');
|
||
const errs = Object.entries(d.errors || {});
|
||
toast(t('discovered', { n: d.found }) + (errs.length ? ' — ' + errs.map(([c, e]) => c + ' : ' + tm(e)).join(' ; ') : ''), errs.length > 0);
|
||
} catch (e) {
|
||
if (e.message !== 'auth') toast(e.message, true);
|
||
} finally {
|
||
busy(false);
|
||
}
|
||
loadNodes();
|
||
}
|
||
|
||
let editingNode = null;
|
||
function editNode(n) {
|
||
editingNode = n;
|
||
$('#nodeTitle').textContent = t('editNode', { n: n.key });
|
||
$('#nHost').value = n.sshHost || '';
|
||
$('#nHost').placeholder = n.ip || '';
|
||
$('#nPort').value = n.sshPort && n.sshPort !== 22 ? n.sshPort : '';
|
||
$('#nPassword').value = '';
|
||
$('#nPassword').placeholder = t('nodePwdKeep', { w: t(n.hasPassword ? 'pwdOwn' : 'pwdCluster') });
|
||
$('#nClear').checked = !n.hasPassword;
|
||
$('#nKey').textContent = n.sshFingerprint || t('sshKeyNone');
|
||
$('#nForget').hidden = !n.sshFingerprint;
|
||
$('#nErr').hidden = true;
|
||
$('#nodeDlg').showModal();
|
||
$('#nHost').focus();
|
||
}
|
||
async function saveNode(ev) {
|
||
ev.preventDefault();
|
||
const n = editingNode;
|
||
const pwd = $('#nPassword').value;
|
||
try {
|
||
await api('PUT', nodePath(n), { sshHost: $('#nHost').value.trim(), sshPort: Number($('#nPort').value) || 22,
|
||
password: pwd, clearPassword: !pwd && $('#nClear').checked && n.hasPassword });
|
||
$('#nodeDlg').close();
|
||
toast(t('saved'));
|
||
await loadNodes();
|
||
checkNode(n, null);
|
||
} catch (e) {
|
||
$('#nErr').textContent = e.message;
|
||
$('#nErr').hidden = false;
|
||
}
|
||
}
|
||
async function forgetNode() {
|
||
const n = editingNode;
|
||
if (!n || !confirm(t('confirmForget', { c: n.key }))) return;
|
||
try {
|
||
await api('POST', nodePath(n) + '/forget');
|
||
$('#nodeDlg').close();
|
||
loadNodes();
|
||
} catch (e) {
|
||
$('#nErr').textContent = e.message;
|
||
$('#nErr').hidden = false;
|
||
}
|
||
}
|
||
|
||
// profile editor
|
||
let editingProfile = null;
|
||
function editProfile(p) {
|
||
editingProfile = p || null;
|
||
p = p || { name: '', master: '', targets: [], items: ['network', 'dns'], prune: false, policies: {}, protect: {}, mapping: {} };
|
||
$('#profileTitle').textContent = editingProfile ? t('editProfile', { p: p.name }) : t('newProfile');
|
||
$('#pName').value = p.name;
|
||
const keys = S.nodes.map((n) => n.key);
|
||
if (p.master && !keys.includes(p.master)) keys.unshift(p.master);
|
||
$('#pMaster').replaceChildren(h('option', { value: '', text: '—' }), ...keys.map((k) => h('option', { value: k, text: k })));
|
||
$('#pMaster').value = p.master;
|
||
renderTargetChips(p.targets || []);
|
||
$('#pItems').replaceChildren(...NITEMS.map((k) => chip(k, t('kinds.' + k), (p.items || []).includes(k))));
|
||
$('#pPrune').checked = !!p.prune;
|
||
const pr = $('#pPolRows');
|
||
pr.replaceChildren();
|
||
for (const [k, fields] of Object.entries(p.policies || {})) for (const [f, x] of Object.entries(fields || {})) pr.appendChild(nodePolRow(k, f, x));
|
||
$('#pProtect').replaceChildren(...NITEMS.filter((k) => k !== 'network' && k !== 'dns').map((k) => h('div', { class: 'pprot', dataset: { item: k } },
|
||
h('label', { text: t('kinds.' + k) }),
|
||
h('textarea', { class: 'inp', spellcheck: 'false', placeholder: k === 'chrony' ? '^server .* iburst$' : '^# host ' }, ((p.protect || {})[k] || []).join('\n')))));
|
||
renderMapRows(p);
|
||
$('#pDelete').hidden = !editingProfile;
|
||
$('#pErr').hidden = true;
|
||
$('#profileDlg').showModal();
|
||
$('#pName').focus();
|
||
}
|
||
function chip(value, label, on, onclick) {
|
||
return h('button', { type: 'button', class: 'chip' + (on ? ' on' : ''), dataset: { v: value }, 'aria-pressed': String(on),
|
||
onclick: (ev) => {
|
||
const b = ev.currentTarget;
|
||
b.classList.toggle('on');
|
||
b.setAttribute('aria-pressed', String(b.classList.contains('on')));
|
||
if (onclick) onclick();
|
||
} }, label);
|
||
}
|
||
const chipsOn = (sel) => $$(sel + ' .chip.on').map((c) => c.dataset.v);
|
||
function renderTargetChips(targets) {
|
||
const master = $('#pMaster').value;
|
||
const keys = S.nodes.map((n) => n.key).filter((k) => k !== master);
|
||
for (const x of targets) if (!keys.includes(x) && x !== master) keys.push(x);
|
||
$('#pTargets').replaceChildren(...keys.map((k) => chip(k, k, targets.includes(k), () => renderMapRows(readProfile()))));
|
||
}
|
||
function nodePolRow(item, field, pol) {
|
||
const ks = h('select', { class: 'field' }, ...['network', 'dns'].map((k) => h('option', { value: k, text: t('kindShort.' + k) })));
|
||
ks.value = item || 'network';
|
||
const fieldsOf = (k) => k === 'dns' ? ['search', 'dns1', 'dns2', 'dns3']
|
||
: ['autostart', 'bond-primary', 'bond_mode', 'bond_xmit_hash_policy', 'bridge_ports', 'bridge_vids', 'bridge_vlan_aware', 'cidr', 'cidr6',
|
||
'comments', 'gateway', 'gateway6', 'mtu', 'slaves', 'vlan-id', 'vlan-raw-device'];
|
||
const [fdl, fList] = datalist(fieldsOf(ks.value));
|
||
const ps = h('select', { class: 'field', 'data-k': 'policy' }, ...POLICIES.map((x) => h('option', { value: x, text: x })));
|
||
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(), 'danger'), fList);
|
||
ks.addEventListener('change', () => fList.replaceChildren(...fieldsOf(ks.value).map((v) => h('option', { value: v }))));
|
||
return row;
|
||
}
|
||
// renderMapRows: one block per target, NIC names and own values
|
||
function renderMapRows(p) {
|
||
const box = $('#pMapRows');
|
||
const prev = {};
|
||
for (const b of $$('.pmap', box)) prev[b.dataset.target] = { nics: $('[data-k="nics"]', b).value, values: $('[data-k="values"]', b).value };
|
||
box.replaceChildren();
|
||
for (const tg of p.targets || []) {
|
||
const m = (p.mapping || {})[tg] || {};
|
||
const nics = prev[tg] ? prev[tg].nics : Object.entries(m.nics || {}).map(([a, b]) => a + '=' + b).join(', ');
|
||
const vals = prev[tg] ? prev[tg].values : Object.entries(m.values || {}).flatMap(([key, fields]) => {
|
||
const [item, id] = [key.slice(0, key.indexOf('/')), key.slice(key.indexOf('/') + 1)];
|
||
return Object.entries(fields).map(([f, v]) => (item === 'dns' ? 'dns' : id) + ' ' + f + '=' + v);
|
||
}).join('\n');
|
||
box.appendChild(h('div', { class: 'pmap', dataset: { target: tg } },
|
||
h('b', { text: tg }),
|
||
h('label', { text: t('mapNics') }),
|
||
h('input', { class: 'inp mono', 'data-k': 'nics', value: nics, placeholder: 'eno1=enp65s0f0, eno2=enp65s0f1', spellcheck: 'false' }),
|
||
h('label', { text: t('mapValues') }),
|
||
h('textarea', { class: 'inp', 'data-k': 'values', spellcheck: 'false', placeholder: 'vmbr1 cidr=10.5.0.12/24\ndns dns1=10.0.0.53' }, vals)));
|
||
}
|
||
}
|
||
// readProfile reads the editor; bad lines of the mapping are reported in errs
|
||
function readProfile(errs) {
|
||
const p = {
|
||
name: $('#pName').value.trim(), master: $('#pMaster').value, targets: chipsOn('#pTargets'), items: NITEMS.filter((k) => chipsOn('#pItems').includes(k)),
|
||
prune: $('#pPrune').checked, policies: {}, protect: {}, mapping: {}, position: editingProfile ? editingProfile.position : S.profiles.length,
|
||
};
|
||
for (const row of $$('#pPolRows .erow')) {
|
||
const k = $('select', row).value;
|
||
const f = $('[data-k="field"]', row).value.trim();
|
||
if (f) (p.policies[k] = p.policies[k] || {})[f] = $('[data-k="policy"]', row).value;
|
||
}
|
||
for (const b of $$('#pProtect .pprot')) {
|
||
const l = $('textarea', b).value.split('\n').map((x) => x.trim()).filter(Boolean);
|
||
if (l.length) p.protect[b.dataset.item] = l;
|
||
}
|
||
for (const b of $$('#pMapRows .pmap')) {
|
||
const m = { nics: {}, values: {} };
|
||
for (const pair of $('[data-k="nics"]', b).value.split(',').map((x) => x.trim()).filter(Boolean)) {
|
||
const [a, c] = pair.split('=').map((x) => (x || '').trim());
|
||
if (a && c) m.nics[a] = c; else if (errs) errs.push(b.dataset.target + ' : ' + pair);
|
||
}
|
||
for (const line of $('[data-k="values"]', b).value.split('\n').map((x) => x.trim()).filter(Boolean)) {
|
||
const mm = line.match(/^(\S+)\s+([\w-]+)\s*=\s*(.*)$/);
|
||
if (!mm) { if (errs) errs.push(b.dataset.target + ' : ' + line); continue; }
|
||
const key = mm[1] === 'dns' ? 'dns/dns' : 'network/' + mm[1];
|
||
(m.values[key] = m.values[key] || {})[mm[2]] = mm[3].trim();
|
||
}
|
||
if (Object.keys(m.nics).length || Object.keys(m.values).length) p.mapping[b.dataset.target] = m;
|
||
}
|
||
return p;
|
||
}
|
||
async function saveProfile(ev) {
|
||
ev.preventDefault();
|
||
const errs = [];
|
||
const p = readProfile(errs);
|
||
if (errs.length) {
|
||
$('#pErr').textContent = t('bulkBadLine', { l: errs.join(' ; ') });
|
||
$('#pErr').hidden = false;
|
||
return;
|
||
}
|
||
try {
|
||
await api('PUT', 'api/profiles/' + encodeURIComponent(editingProfile ? editingProfile.name : '-'), p);
|
||
$('#profileDlg').close();
|
||
toast(t('saved'));
|
||
S.profile = p.name;
|
||
store.set('profile', p.name);
|
||
MODES.node.state = null;
|
||
await loadNodes();
|
||
loadNodeState(true);
|
||
} catch (e) {
|
||
$('#pErr').textContent = e.message;
|
||
$('#pErr').hidden = false;
|
||
}
|
||
}
|
||
async function deleteProfile() {
|
||
if (!editingProfile || !confirm(t('confirmDeleteProfile', { p: editingProfile.name }))) return;
|
||
try {
|
||
await api('DELETE', 'api/profiles/' + encodeURIComponent(editingProfile.name));
|
||
$('#profileDlg').close();
|
||
MODES.node.state = null;
|
||
await loadNodes();
|
||
loadNodeState(false);
|
||
} catch (e) {
|
||
$('#pErr').textContent = e.message;
|
||
$('#pErr').hidden = false;
|
||
}
|
||
}
|
||
|
||
// ---------- events ----------
|
||
function bind() {
|
||
$('#refreshBtn').addEventListener('click', () => {
|
||
if (S.view === 'diff') loadState(true);
|
||
else if (S.view === 'runs') loadRuns(true);
|
||
else if (S.view === 'clusters') loadClusters();
|
||
else if (S.view === 'nodes') loadNodes();
|
||
else loadAudit(true);
|
||
});
|
||
$$('#modeSw [data-mode]').forEach((b) => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||
$('#profileSel').addEventListener('change', (e) => {
|
||
S.profile = e.target.value;
|
||
store.set('profile', S.profile);
|
||
MODES.node.state = null;
|
||
S.open.clear();
|
||
loadNodeState(false);
|
||
});
|
||
$('#addProfile').addEventListener('click', () => editProfile(null));
|
||
$('#discoverBtn').addEventListener('click', discoverNodes);
|
||
$('#testNodes').addEventListener('click', testNodes);
|
||
$('#nodeForm').addEventListener('submit', saveNode);
|
||
$('#nForget').addEventListener('click', forgetNode);
|
||
$('#profileForm').addEventListener('submit', saveProfile);
|
||
$('#pDelete').addEventListener('click', deleteProfile);
|
||
$('#pMaster').addEventListener('change', () => { const p = readProfile(); renderTargetChips(p.targets); renderMapRows(readProfile()); });
|
||
$('#pPolAdd').addEventListener('click', () => $('#pPolRows').appendChild(nodePolRow('network', '', 'sync')));
|
||
$('#settingsBtn').addEventListener('click', () => openSettings());
|
||
$('#setupBtn').addEventListener('click', () => {
|
||
if (isNode()) {
|
||
setView('nodes');
|
||
if (can.admin()) editProfile(null);
|
||
return;
|
||
}
|
||
setView('clusters');
|
||
if (S.setup === 'clusters' && can.admin()) editCluster(null);
|
||
});
|
||
$('#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(globalSel()));
|
||
$('#syncAllBtn').addEventListener('click', () => openPlan(globalSel(), true));
|
||
// one drop-down menu open at a time, closed by a click outside
|
||
document.addEventListener('click', (e) => {
|
||
$$('details.menu[open]').forEach((d) => { if (!d.contains(e.target)) d.open = false; });
|
||
});
|
||
$('#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));
|
||
$('#addBulk').addEventListener('click', openBulk);
|
||
$('#bulkForm').addEventListener('submit', addBulk);
|
||
$('#testAll').addEventListener('click', testAll);
|
||
$('#cForget').addEventListener('click', forgetPins);
|
||
$('#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(); });
|
||
$('#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();
|
||
try {
|
||
const d = await api('GET', 'api/settings');
|
||
S.settings = d.settings;
|
||
S.deps = d.deps || {};
|
||
} catch (e) { /* the replicated items stay read only */ }
|
||
if (S.view === 'clusters') loadClusters();
|
||
if (S.view === 'nodes') loadNodes();
|
||
await loadState(false);
|
||
if (S.view === 'runs') loadRuns(true);
|
||
if (S.view === 'audit') loadAudit(true);
|
||
}
|
||
init();
|
||
})();
|