Full web : fichiers restants
This commit is contained in:
1 parent
ec3b7c9a01
commit
4826fa5b82
26 files changed
+2537
-793
No files matched your search
+415
-159
@@ -1,5 +1,6 @@
|
||||
// pve-confsync web UI: clusters, differences per client, dry-run and synchronization
|
||||
// (per object, per client, global), execution journal, audit trail and settings.
|
||||
// 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 () {
|
||||
@@ -9,73 +10,90 @@
|
||||
const I18N = {
|
||||
fr: {
|
||||
demo: 'DÉMO', searchPh: 'Filtrer les objets (nom, champ, valeur)', refresh: 'Actualiser',
|
||||
refreshTitle: 'Relire la référence et les clients', settings: 'Paramètres', theme: 'Thème clair / sombre',
|
||||
logout: 'Se déconnecter', viewAria: 'Affichage', viewDiff: 'Écarts', viewRuns: 'Journal', viewAudit: 'Audit',
|
||||
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run global', syncAll: 'Tout synchroniser',
|
||||
setupText: "Aucun cluster de référence n'est défini : ajoutez la référence puis les clients.", setupBtn: 'Ajouter les clusters',
|
||||
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',
|
||||
kindMenu: 'Éléments répliqués', targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.',
|
||||
kindMenuRO: 'Seuls les administrateurs changent les éléments répliqués.',
|
||||
kindMenuHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte : ils sont cochés ensemble.',
|
||||
kindGroups: { dc: 'Datacenter', access: 'Comptes et droits (realm pve)', sdn: 'SDN' },
|
||||
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: 'Clients', colSummary: 'Résumé',
|
||||
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: 'Synchroniser', addRow: '+ Ajouter',
|
||||
kinds: { 'datacenter-options': 'Options datacenter', 'sdn-zone': 'Zones SDN', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' },
|
||||
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' },
|
||||
kinds: {
|
||||
'datacenter-options': 'Options datacenter', 'pve-group': 'Groupes', 'pve-role': 'Rôles personnalisés',
|
||||
'pve-user': 'Comptes du realm pve', 'pve-password': 'Mots de passe (SSH)',
|
||||
'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': 'mot de passe',
|
||||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||||
},
|
||||
allKinds: 'Tous les types',
|
||||
st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors référence', error: 'Erreur' },
|
||||
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: 'RÉF', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run du client', syncClient: 'Synchroniser le client',
|
||||
reference: 'PRIMAIRE', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Synchroniser la cible',
|
||||
dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet',
|
||||
refError: 'Référence illisible', clientError: 'Client illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||||
fieldsN: '{n} champ(s)', noFields: 'aucun champ',
|
||||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Référence', thMapping: 'Mapping client', thCurrent: 'Client actuel', thTarget: 'Après synchro',
|
||||
emptyOk: 'Aucun écart pour ce client.', emptyOkHint: 'Le client est conforme à la référence pour les types synchronisés.',
|
||||
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 synchro',
|
||||
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: 'Aucun client.', noClientHint: 'Ajoutez des clusters clients dans les paramètres.',
|
||||
connOk: 'Connecté', connKo: 'Hors ligne', statsLine: 'Référence {ref} · {n} client(s) · {d} avec écarts · lu à {t}',
|
||||
dryTitle: 'Dry-run : {what}', whatGlobal: 'tous les clients', whatClient: 'client {c}', whatObject: '{o} sur {c}',
|
||||
reading: 'Lecture des clusters…', planEmpty: 'Rien à faire : conforme.', planErrors: 'Erreurs bloquantes : rien ne sera appliqué sur ce client.',
|
||||
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 clients. Tapez le nom du cluster de référence « {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: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
||||
relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était',
|
||||
res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' },
|
||||
mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'global', client: 'client', object: 'objet' },
|
||||
mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
||||
allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs',
|
||||
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
||||
action: {
|
||||
login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro',
|
||||
setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro',
|
||||
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
|
||||
'settings-save': 'paramètres', 'config-export': 'export YAML', 'config-import': 'import YAML',
|
||||
'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',
|
||||
},
|
||||
tabClusters: 'Clusters', tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabConfig: 'Import / export', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||||
tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||||
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
|
||||
addCluster: '+ Ajouter un cluster',
|
||||
clustersHelp: "Un cluster de référence (token en lecture seule : Sys.Audit, SDN.Audit) et des clients (Sys.Audit, Sys.Modify si les options datacenter sont synchronisées, SDN.Audit, SDN.Allocate). Les secrets des tokens sont chiffrés en base et ne sont jamais réaffichés.",
|
||||
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' },
|
||||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle', roleClient: 'Client', roleRef: 'Référence',
|
||||
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||||
tlsFp: 'Empreinte SHA-256 du certificat', tlsCa: "Certificat d'autorité (PEM)", tlsSys: 'Autorités du système', tlsInsecure: 'Sans vérification (labo uniquement)',
|
||||
secretKeep: 'Laisser vide pour conserver le secret enregistré.', secretNew: 'Secret du token API (UUID), chiffré en base.',
|
||||
cMapping: 'Mapping (valeurs propres au client)', cMappingHelp: "Valeurs prioritaires sur la référence pour ce client : bridge, nodes, peers d'une zone, next-id… Les champs en politique « override » ne sont écrits qu'à partir d'ici.",
|
||||
cPolicies: 'Politiques propres au client', cPoliciesHelp: 'Prioritaires sur les politiques globales pour ce client.',
|
||||
confirmDelete: 'Supprimer le cluster {c} ? Ses mappings sont perdus.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
||||
kindsTitle: 'Types synchronisés', kindsHelp: 'Hors périmètre v1 : EVPN, firewall, storage, pools/ACL, jobs, secrets.',
|
||||
pruneTitle: 'Suppression', pruneLabel: 'Supprimer chez les clients les objets absents de la référence',
|
||||
crole: { primary: 'primaire', target: 'cible', disabled: 'inactif' }, 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: 'Inactif',
|
||||
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: 'Un compte a besoin de ses groupes, un mot de passe de son compte. 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-?). Vide : tout.",
|
||||
polTitle: 'Politiques par champ', polHelp: 'sync : copie de la référence ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping du client. Défauts :',
|
||||
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 (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
||||
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 synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
||||
addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire',
|
||||
exportTitle: 'Exporter', exportHelp: 'Configuration au format YAML de la CLI, versionnable dans Git. Les secrets ne sont pas exportés : chaque cluster reçoit une variable token_secret_env à renseigner.',
|
||||
exportBtn: 'Télécharger confsync.yaml', importTitle: 'Importer',
|
||||
importHelp: "Fichier YAML de la CLI : remplace les paramètres de synchronisation, crée ou met à jour les clusters. Les secrets sont lus depuis token_secret_env / token_secret_file s'ils sont accessibles au serveur, sinon le secret déjà enregistré est conservé.",
|
||||
importPick: 'Choisir un fichier…', importReplace: 'Supprimer les clusters absents du fichier', importBtn: 'Importer', importOk: 'Configuration importée.',
|
||||
accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation',
|
||||
pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.',
|
||||
accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC',
|
||||
@@ -85,73 +103,90 @@
|
||||
},
|
||||
en: {
|
||||
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
||||
refreshTitle: 'Read the reference and the clients again', settings: 'Settings', theme: 'Light / dark theme',
|
||||
logout: 'Sign out', viewAria: 'View', viewDiff: 'Differences', viewRuns: 'Journal', viewAudit: 'Audit',
|
||||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Global dry-run', syncAll: 'Sync everything',
|
||||
setupText: 'No reference cluster yet: add the reference, then the clients.', setupBtn: 'Add clusters',
|
||||
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',
|
||||
kindMenu: 'Replicated items', targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.',
|
||||
kindMenuRO: 'Only administrators change the replicated items.',
|
||||
kindMenuHelp: 'An account needs its groups, a password its account: they are checked together.',
|
||||
kindGroups: { dc: 'Datacenter', access: 'Accounts and permissions (pve realm)', sdn: 'SDN' },
|
||||
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: 'Clients', colSummary: 'Summary',
|
||||
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: 'Synchronize', addRow: '+ Add',
|
||||
kinds: { 'datacenter-options': 'Datacenter options', 'sdn-zone': 'SDN zones', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets' },
|
||||
kindShort: { 'datacenter-options': 'options', 'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet' },
|
||||
kinds: {
|
||||
'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
|
||||
'pve-user': 'pve realm accounts', 'pve-password': 'Passwords (SSH)',
|
||||
'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': 'password',
|
||||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||||
},
|
||||
allKinds: 'All kinds',
|
||||
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not in ref.', error: 'Error' },
|
||||
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: 'REF', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Client dry-run', syncClient: 'Synchronize client',
|
||||
reference: 'PRIMARY', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Synchronize target',
|
||||
dryObj: 'Dry-run this object', syncObj: 'Synchronize this object',
|
||||
refError: 'Reference unreadable', clientError: 'Client unreadable', planError: 'Blocking', warning: 'Warning',
|
||||
fieldsN: '{n} field(s)', noFields: 'no field',
|
||||
thField: 'Field', thPolicy: 'Policy', thRef: 'Reference', thMapping: 'Client mapping', thCurrent: 'Client now', thTarget: 'After sync',
|
||||
emptyOk: 'No difference for this client.', emptyOkHint: 'The client is in line with the reference for the synchronized kinds.',
|
||||
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 sync',
|
||||
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 client.', noClientHint: 'Add client clusters in the settings.',
|
||||
connOk: 'Connected', connKo: 'Offline', statsLine: 'Reference {ref} · {n} client(s) · {d} with differences · read at {t}',
|
||||
dryTitle: 'Dry-run: {what}', whatGlobal: 'all clients', whatClient: 'client {c}', whatObject: '{o} on {c}',
|
||||
reading: 'Reading the clusters…', planEmpty: 'Nothing to do: in sync.', planErrors: 'Blocking errors: nothing will be applied on this client.',
|
||||
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 clients. Type the name of the reference cluster "{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: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.',
|
||||
relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was',
|
||||
res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' },
|
||||
mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'global', client: 'client', object: 'object' },
|
||||
mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
||||
allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs',
|
||||
noRuns: 'No run yet.', noAudit: 'No event.',
|
||||
action: {
|
||||
login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync',
|
||||
setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync',
|
||||
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
|
||||
'settings-save': 'settings', 'config-export': 'YAML export', 'config-import': 'YAML import',
|
||||
'cluster-role': 'cluster role', 'cluster-forget': 'fingerprints forgotten', 'settings-save': 'settings',
|
||||
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
|
||||
},
|
||||
tabClusters: 'Clusters', tabSync: 'Synchronization', tabUsers: 'Users', tabConfig: 'Import / export', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||||
tabSync: 'Synchronization', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||||
roNote: 'Read only: only administrators change these settings.',
|
||||
addCluster: '+ Add a cluster',
|
||||
clustersHelp: 'One reference cluster (read-only token: Sys.Audit, SDN.Audit) and clients (Sys.Audit, Sys.Modify when datacenter options are synchronized, SDN.Audit, SDN.Allocate). Token secrets are encrypted in the database and never shown again.',
|
||||
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' },
|
||||
newCluster: 'New cluster', editCluster: 'Cluster {c}', cName: 'Name', cRole: 'Role', roleClient: 'Client', roleRef: 'Reference',
|
||||
cToken: 'Token', cSecret: 'Secret', cTLS: 'TLS', cFp: 'Fingerprint', cTimeout: 'Timeout',
|
||||
tlsFp: 'SHA-256 fingerprint of the certificate', tlsCa: 'Certificate authority (PEM)', tlsSys: 'System authorities', tlsInsecure: 'No verification (lab only)',
|
||||
secretKeep: 'Leave empty to keep the stored secret.', secretNew: 'API token secret (UUID), encrypted in the database.',
|
||||
cMapping: 'Mapping (client specific values)', cMappingHelp: 'Values taking precedence over the reference for this client: bridge, nodes, peers of a zone, next-id… Fields with the "override" policy are only written from here.',
|
||||
cPolicies: 'Client policies', cPoliciesHelp: 'Take precedence over the global policies for this client.',
|
||||
confirmDelete: 'Delete cluster {c}? Its mappings are lost.', confirmDeleteUser: 'Delete user {u}?',
|
||||
kindsTitle: 'Synchronized kinds', kindsHelp: 'Out of scope in v1: EVPN, firewall, storage, pools/ACL, jobs, secrets.',
|
||||
pruneTitle: 'Deletion', pruneLabel: 'Delete on the clients the objects absent from the reference',
|
||||
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: 'An account needs its groups, a password its account. 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-?). Empty: everything.',
|
||||
polTitle: 'Field policies', polHelp: 'sync: copied from the reference; ignore: never read nor written; override: value from the client mapping. Defaults:',
|
||||
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 (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.',
|
||||
usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.',
|
||||
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
|
||||
exportTitle: 'Export', exportHelp: 'Configuration in the YAML format of the CLI, versionable in Git. Secrets are not exported: each cluster gets a token_secret_env variable to fill.',
|
||||
exportBtn: 'Download confsync.yaml', importTitle: 'Import',
|
||||
importHelp: 'CLI YAML file: replaces the synchronization settings, creates or updates the clusters. Secrets are read from token_secret_env / token_secret_file when the server can reach them, otherwise the stored secret is kept.',
|
||||
importPick: 'Choose a file…', importReplace: 'Delete the clusters absent from the file', importBtn: 'Import', importOk: 'Configuration imported.',
|
||||
accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm',
|
||||
pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.',
|
||||
accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC',
|
||||
@@ -160,7 +195,8 @@
|
||||
noRights: 'Your role cannot synchronize.', errorPrefix: 'Error: ',
|
||||
},
|
||||
};
|
||||
const KINDS = ['datacenter-options', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||||
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||||
const KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user', 'pve-password']], ['sdn', ['sdn-zone', 'sdn-vnet', 'sdn-subnet']]];
|
||||
const POLICIES = ['sync', 'ignore', 'override'];
|
||||
const ROLES = ['admin', 'operator', 'viewer'];
|
||||
|
||||
@@ -299,7 +335,11 @@
|
||||
const S = {
|
||||
me: null,
|
||||
state: null, // last comparison
|
||||
setup: false, // no reference yet
|
||||
setup: '', // 'clusters' (empty database) or 'primary' (no primary chosen)
|
||||
clusters: [], // clusters view
|
||||
settings: null, // synchronization settings (replicated kinds)
|
||||
deps: {}, // kind -> kinds it needs
|
||||
skip: new Set(JSON.parse(store.get('skipTargets') || '[]')), // targets left out of the global actions
|
||||
view: 'diff',
|
||||
client: null, // selected client
|
||||
open: new Set(), // expanded object rows (client|key)
|
||||
@@ -353,10 +393,10 @@
|
||||
$$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal'))));
|
||||
}
|
||||
|
||||
// ---------- routing (#diff/<client>, #runs, #audit) ----------
|
||||
// ---------- routing (#diff/<target>, #clusters, #runs, #audit) ----------
|
||||
function readHash() {
|
||||
const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/');
|
||||
S.view = ['diff', 'runs', 'audit'].includes(v) ? v : 'diff';
|
||||
S.view = ['diff', 'clusters', 'runs', 'audit'].includes(v) ? v : 'diff';
|
||||
if (S.view === 'audit' && !can.admin()) S.view = 'diff';
|
||||
if (c) S.client = c;
|
||||
}
|
||||
@@ -370,6 +410,7 @@
|
||||
renderView();
|
||||
if (v === 'runs') loadRuns(true);
|
||||
if (v === 'audit') loadAudit(true);
|
||||
if (v === 'clusters') loadClusters();
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
@@ -378,7 +419,7 @@
|
||||
$('#refreshBtn').classList.add('spin');
|
||||
try {
|
||||
const d = await api('GET', 'api/state' + (refresh ? '?refresh=1' : ''));
|
||||
S.setup = !!d.setup;
|
||||
S.setup = d.setup || '';
|
||||
S.state = d.state || null;
|
||||
$('#error').hidden = true;
|
||||
if (S.state) {
|
||||
@@ -412,13 +453,20 @@
|
||||
function renderView() {
|
||||
$$('#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';
|
||||
$('#runsView').hidden = S.view !== 'runs';
|
||||
$('#auditView').hidden = S.view !== 'audit';
|
||||
$$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; });
|
||||
$$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; });
|
||||
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
|
||||
$('#setup').hidden = !S.setup;
|
||||
if (S.view === 'diff') renderDiff();
|
||||
$('#setup').hidden = !S.setup || S.view === 'clusters';
|
||||
if (S.setup) {
|
||||
$('#setupText').textContent = t(S.setup === 'clusters' ? 'setupClusters' : 'setupPrimary');
|
||||
$('#setupBtn').textContent = t(S.setup === 'clusters' ? 'setupClustersBtn' : 'setupPrimaryBtn');
|
||||
}
|
||||
$$('[data-admin-only]').forEach((e) => { e.hidden = !can.admin(); });
|
||||
if (S.view === 'diff') { renderDiff(); renderKindMenu(); renderTargetMenu(); }
|
||||
if (S.view === 'clusters') renderClusters();
|
||||
if (S.view === 'runs') renderRuns();
|
||||
if (S.view === 'audit') renderAudit();
|
||||
}
|
||||
@@ -450,7 +498,7 @@
|
||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||
h('div', { class: 'c-url', text: r.url }),
|
||||
r.error ? h('div', { class: 'c-err', text: r.error, title: r.error })
|
||||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('fieldsN', { n: r.objects }).replace(/champ\(s\)|field\(s\)/, lang === 'fr' ? 'objet(s)' : 'object(s)') }),
|
||||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
|
||||
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);
|
||||
@@ -632,7 +680,7 @@
|
||||
const plan = { sel: null, plans: null, run: 0, poll: 0 };
|
||||
|
||||
function whatLabel(sel) {
|
||||
if (sel.scope === 'global') return t('whatGlobal');
|
||||
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 });
|
||||
}
|
||||
@@ -898,7 +946,7 @@
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
const set = { tab: 'clusters', clusters: [], settings: null, defaults: {}, dirty: false };
|
||||
const set = { tab: 'sync', settings: null, defaults: {}, dirty: false };
|
||||
|
||||
function openSettings(tab) {
|
||||
const dlg = $('#settingsDlg');
|
||||
@@ -912,12 +960,11 @@
|
||||
showTab(tab || set.tab);
|
||||
}
|
||||
function showTab(tab) {
|
||||
if ((tab === 'users' || tab === 'config') && !can.admin()) tab = 'clusters';
|
||||
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 === 'clusters') loadClusters();
|
||||
if (tab === 'sync') loadSettings();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'account') renderAccount();
|
||||
@@ -928,63 +975,129 @@
|
||||
}
|
||||
}
|
||||
|
||||
// clusters
|
||||
// ---------- clusters view ----------
|
||||
async function loadClusters() {
|
||||
try {
|
||||
const d = await api('GET', 'api/clusters');
|
||||
set.clusters = d.clusters;
|
||||
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 ? ' — ' + k.error : '') });
|
||||
}
|
||||
function renderClusters() {
|
||||
const list = $('#clusterList');
|
||||
list.replaceChildren();
|
||||
if (!set.clusters.length) list.appendChild(h('p', { class: 'muted', text: t('setupText') }));
|
||||
for (const c of set.clusters) {
|
||||
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 });
|
||||
list.appendChild(h('div', { class: 'citem' + (c.reference ? ' ref' : '') },
|
||||
h('div', { class: 'n' }, c.reference ? h('span', { class: 'c-role', text: t('reference') }) : null, h('span', { text: c.name })),
|
||||
h('div', { class: 'u', text: c.url + (c.tokenId ? ' · ' + c.tokenId : ''), title: c.url }),
|
||||
can.admin() ? h('div', { class: 'acts' },
|
||||
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'),
|
||||
chk));
|
||||
meta, chk));
|
||||
}
|
||||
}
|
||||
async function checkCluster(name, el) {
|
||||
el.hidden = false;
|
||||
el.className = 'chk muted';
|
||||
el.textContent = t('testing');
|
||||
async function setRole(name, role) {
|
||||
try {
|
||||
const r = await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/check');
|
||||
const nodes = (r.nodes || []).map((n) => (n.name ? n.name + ' ' : '') + 'PVE ' + n.version).join(', ');
|
||||
if (r.error) { el.className = 'chk ko'; el.textContent = '✗ ' + r.error; return; }
|
||||
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
|
||||
el.textContent = '✓ ' + t('testOk') + (nodes ? ' — ' + nodes : '') + ((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : '');
|
||||
await api('POST', 'api/clusters/' + encodeURIComponent(name) + '/role', { role });
|
||||
toast(t('saved'));
|
||||
} catch (e) {
|
||||
el.className = 'chk ko';
|
||||
el.textContent = '✗ ' + e.message;
|
||||
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('✗ ' + 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.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: '', reference: !set.clusters.some((x) => x.reference), url: '', tokenId: '', fingerprint: '', caPem: '', insecure: false, timeout: '', mapping: {}, policies: {} };
|
||||
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.reference ? 'ref' : 'client');
|
||||
setSeg('#cRole', c.role);
|
||||
$('#cURL').value = c.url;
|
||||
$('#cToken').value = c.tokenId;
|
||||
$('#cSecret').value = '';
|
||||
$('#cSecret').required = isNew || !c.hasSecret;
|
||||
$('#cSecretHint').textContent = isNew || !c.hasSecret ? t('secretNew') : t('secretKeep');
|
||||
$('#cTLS').value = c.fingerprint ? 'fp' : c.caPem ? 'ca' : c.insecure ? 'insecure' : (isNew ? 'fp' : 'sys');
|
||||
$('#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();
|
||||
@@ -1009,11 +1122,11 @@
|
||||
const v = $('#cTLS').value;
|
||||
$$('#clusterDlg [data-tls]').forEach((e) => { e.hidden = e.dataset.tls !== v; });
|
||||
}
|
||||
function roleFields() { $('#cClientOnly').hidden = segVal('#cRole') === 'ref'; }
|
||||
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 || KINDS[1];
|
||||
s.value = v || 'sdn-zone';
|
||||
return s;
|
||||
}
|
||||
function refIds(kind) {
|
||||
@@ -1088,21 +1201,25 @@
|
||||
const tls = $('#cTLS').value;
|
||||
const c = {
|
||||
name: $('#cName').value.trim(),
|
||||
reference: segVal('#cRole') === 'ref',
|
||||
position: editing ? editing.position : set.clusters.length,
|
||||
role: segVal('#cRole') || 'target',
|
||||
position: editing ? editing.position : S.clusters.length,
|
||||
url: $('#cURL').value.trim(),
|
||||
tokenId: $('#cToken').value.trim(),
|
||||
secret: $('#cSecret').value.trim(),
|
||||
hasSecret: false,
|
||||
user: 'root@pam',
|
||||
password: $('#cPassword').value,
|
||||
hasPassword: false,
|
||||
tls,
|
||||
fingerprint: tls === 'fp' ? $('#cFp').value.trim() : '',
|
||||
caPem: tls === 'ca' ? $('#cCA').value.trim() : '',
|
||||
insecure: tls === 'insecure',
|
||||
timeout: $('#cTimeout').value.trim(),
|
||||
sshHost: $('#cSSHHost').value.trim(),
|
||||
sshPort: Number($('#cSSHPort').value) || 22,
|
||||
sshHostKey: '',
|
||||
sshFingerprint: '',
|
||||
mapping: {},
|
||||
policies: {},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (!c.reference) {
|
||||
if (c.role !== 'primary') {
|
||||
for (const row of $$('#cMapRows .erow')) {
|
||||
const k = $('select', row).value;
|
||||
const id = $('[data-k="id"]', row).value.trim();
|
||||
@@ -1112,12 +1229,14 @@
|
||||
}
|
||||
c.policies = readPolicies($('#cPolRows'));
|
||||
}
|
||||
if (!c.secret) delete c.secret;
|
||||
if (!c.password) delete c.password;
|
||||
try {
|
||||
await api('PUT', 'api/clusters/' + encodeURIComponent(editing ? editing.name : '-'), c);
|
||||
$('#clusterDlg').close();
|
||||
toast(t('saved'));
|
||||
loadClusters();
|
||||
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;
|
||||
@@ -1136,6 +1255,67 @@
|
||||
$('#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 : '') : 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() {
|
||||
@@ -1143,6 +1323,8 @@
|
||||
const d = await api('GET', 'api/settings');
|
||||
set.settings = d.settings;
|
||||
set.defaults = d.defaults || {};
|
||||
S.settings = d.settings;
|
||||
S.deps = d.deps || {};
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
return;
|
||||
@@ -1155,7 +1337,11 @@
|
||||
const chips = $('#kindChips');
|
||||
chips.replaceChildren(...KINDS.map((k) => h('button', {
|
||||
type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)),
|
||||
onclick: (ev) => { ev.currentTarget.classList.toggle('on'); ev.currentTarget.setAttribute('aria-pressed', String(ev.currentTarget.classList.contains('on'))); setDirty(true); },
|
||||
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');
|
||||
@@ -1198,12 +1384,85 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- menus of the filter bar: replicated items, targets ----------
|
||||
// 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); (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 drop(k);
|
||||
return KINDS.filter((x) => out.has(x));
|
||||
}
|
||||
function renderKindMenu() {
|
||||
const pop = $('#kindMenuPop');
|
||||
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
||||
$('#kindMenuN').textContent = kinds.length + '/' + KINDS.length;
|
||||
const admin = can.admin();
|
||||
pop.replaceChildren();
|
||||
for (const [g, ks] of KIND_GROUPS) {
|
||||
pop.appendChild(h('div', { class: 'grp', text: t('kindGroups.' + g) }));
|
||||
for (const k of ks) {
|
||||
pop.appendChild(h('label', { class: 'opt' },
|
||||
h('input', { type: 'checkbox', checked: kinds.includes(k), disabled: !admin || !S.settings,
|
||||
onchange: (ev) => saveKinds(withDeps(kinds, k, ev.target.checked)) }),
|
||||
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }), h('span', { text: t('kinds.' + k) })));
|
||||
}
|
||||
}
|
||||
pop.appendChild(h('div', { class: 'foot' }, h('span', { class: 'note-s', text: admin ? t('kindMenuHelp') : t('kindMenuRO') })));
|
||||
}
|
||||
async function saveKinds(kinds) {
|
||||
if (!kinds.length) { renderKindMenu(); return; }
|
||||
const body = Object.assign({}, S.settings, { kinds });
|
||||
try {
|
||||
await api('PUT', 'api/settings', body);
|
||||
S.settings = body;
|
||||
renderKindMenu();
|
||||
loadState(true);
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
renderKindMenu();
|
||||
}
|
||||
}
|
||||
// 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('skipTargets', 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();
|
||||
return { scope: 'global', targets: sel.length === names.length ? [] : sel };
|
||||
}
|
||||
|
||||
// users
|
||||
async function loadUsers() {
|
||||
let users;
|
||||
@@ -1239,20 +1498,6 @@
|
||||
return row;
|
||||
}
|
||||
|
||||
// import
|
||||
async function importConfig() {
|
||||
const res = $('#importRes');
|
||||
res.replaceChildren();
|
||||
try {
|
||||
const d = await api('POST', 'api/config/import', { yaml: $('#importText').value, replace: $('#importReplace').checked });
|
||||
res.appendChild(note('info', 'OK', t('importOk')));
|
||||
for (const w of d.warnings || []) res.appendChild(note('warn', t('warning'), w));
|
||||
loadState(true);
|
||||
} catch (e) {
|
||||
res.appendChild(note('err', t('st.error'), e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// account
|
||||
function renderAccount() {
|
||||
const me = S.me;
|
||||
@@ -1278,7 +1523,10 @@
|
||||
function bind() {
|
||||
$('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); });
|
||||
$('#settingsBtn').addEventListener('click', () => openSettings());
|
||||
$('#setupBtn').addEventListener('click', () => openSettings('clusters'));
|
||||
$('#setupBtn').addEventListener('click', () => {
|
||||
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;
|
||||
@@ -1293,8 +1541,12 @@
|
||||
clearTimeout(qTimer);
|
||||
qTimer = setTimeout(() => { S.q = e.target.value.trim(); renderView(); }, 120);
|
||||
});
|
||||
$('#dryAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }));
|
||||
$('#syncAllBtn').addEventListener('click', () => openPlan({ scope: 'global' }, true));
|
||||
$('#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));
|
||||
@@ -1304,6 +1556,10 @@
|
||||
// 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);
|
||||
@@ -1315,12 +1571,6 @@
|
||||
$('#setSave').addEventListener('click', saveSettings);
|
||||
$('#setCancel').addEventListener('click', () => { renderSettings(); setDirty(false); });
|
||||
$('#userAdd').addEventListener('click', () => { const r = userRow(null); $('#userRows').appendChild(r); $('input', r).focus(); });
|
||||
$('#importPick').addEventListener('click', () => $('#importFile').click());
|
||||
$('#importFile').addEventListener('change', async (e) => {
|
||||
const f = e.target.files[0];
|
||||
if (f) $('#importText').value = await f.text();
|
||||
});
|
||||
$('#importBtn').addEventListener('click', importConfig);
|
||||
$('#pwdForm').addEventListener('submit', changePassword);
|
||||
$$('#langSwitch [data-lang]').forEach((b) => b.addEventListener('click', () => { lang = b.dataset.lang; store.set('lang', lang); applyLang(); }));
|
||||
$$('#themeSwitch [data-theme-opt]').forEach((b) => b.addEventListener('click', () => setTheme(b.dataset.themeOpt)));
|
||||
@@ -1358,6 +1608,12 @@
|
||||
$('#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 menu stays read only */ }
|
||||
if (S.view === 'clusters') loadClusters();
|
||||
await loadState(false);
|
||||
if (S.view === 'runs') loadRuns(true);
|
||||
if (S.view === 'audit') loadAudit(true);
|
||||
|
||||
+87
-65
@@ -51,6 +51,11 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
|
||||
<span data-i18n="viewDiff">Écarts</span>
|
||||
</button>
|
||||
<button type="button" role="radio" data-view="clusters">
|
||||
<!-- Lucide "server" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
|
||||
<span data-i18n="viewClusters">Clusters</span>
|
||||
</button>
|
||||
<button type="button" role="radio" data-view="runs">
|
||||
<!-- Lucide "history" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
||||
@@ -62,12 +67,28 @@
|
||||
<span data-i18n="viewAudit">Audit</span>
|
||||
</button>
|
||||
</div>
|
||||
<details id="kindMenu" class="menu" data-diff>
|
||||
<summary>
|
||||
<!-- Lucide "list-checks" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4M3 7l2 2 4-4M13 6h8M13 12h8M13 18h8"/></svg>
|
||||
<span data-i18n="kindMenu">Éléments répliqués</span><span class="n" id="kindMenuN"></span>
|
||||
</summary>
|
||||
<div class="pop" id="kindMenuPop"></div>
|
||||
</details>
|
||||
<select id="kindSel" data-diff data-i18n-aria="kindAria"></select>
|
||||
<select id="statusSel" data-diff data-i18n-aria="statusAria"></select>
|
||||
<select id="runModeSel" data-runs data-i18n-aria="modeAria" hidden></select>
|
||||
<span class="spacer"></span>
|
||||
<span id="count" class="muted"></span>
|
||||
<div id="globalActs" class="global-acts" data-diff hidden>
|
||||
<details id="targetMenu" class="menu right">
|
||||
<summary>
|
||||
<!-- Lucide "target" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||
<span data-i18n="targetMenu">Cibles</span><span class="n" id="targetMenuN"></span>
|
||||
</summary>
|
||||
<div class="pop" id="targetMenuPop"></div>
|
||||
</details>
|
||||
<button id="dryAllBtn" class="btn tool" type="button">
|
||||
<!-- Lucide "scan-search" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="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-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/></svg>
|
||||
@@ -82,8 +103,8 @@
|
||||
|
||||
<div id="error" class="error-banner" hidden></div>
|
||||
<div id="setup" class="setup" hidden>
|
||||
<p data-i18n="setupText">Aucun cluster de référence n'est défini.</p>
|
||||
<button id="setupBtn" class="btn primary" type="button" data-i18n="setupBtn">Ajouter les clusters</button>
|
||||
<p id="setupText"></p>
|
||||
<button id="setupBtn" class="btn primary" type="button"></button>
|
||||
</div>
|
||||
|
||||
<!-- Differences: clusters, then the objects of the selected client -->
|
||||
@@ -108,6 +129,17 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clusters: connection (root), primary / targets, connection tests -->
|
||||
<section id="clustersView" class="cl-view" hidden>
|
||||
<div class="cl-tools">
|
||||
<p class="muted small" data-i18n="clustersHelp"></p>
|
||||
<button id="addCluster" class="btn tool primary" type="button" data-admin-only data-i18n="addCluster">+ Ajouter un cluster</button>
|
||||
<button id="addBulk" class="btn tool" type="button" data-admin-only data-i18n="addBulk">+ Ajouter une liste</button>
|
||||
<button id="testAll" class="btn tool" type="button" data-admin-only data-i18n="testAll">Tout tester</button>
|
||||
</div>
|
||||
<div id="clusterList" class="clist"></div>
|
||||
</section>
|
||||
|
||||
<!-- Execution journal -->
|
||||
<section id="runsView" hidden>
|
||||
<div class="table runs">
|
||||
@@ -185,39 +217,46 @@
|
||||
<input id="cName" class="inp" required autocomplete="off" spellcheck="false">
|
||||
<span class="lbl" data-i18n="cRole">Rôle</span>
|
||||
<div id="cRole" class="seg" role="radiogroup">
|
||||
<button type="button" role="radio" data-v="client" data-i18n="roleClient">Client</button>
|
||||
<button type="button" role="radio" data-v="ref" data-i18n="roleRef">Référence</button>
|
||||
<button type="button" role="radio" data-v="primary" data-i18n="rolePrimary">Primaire</button>
|
||||
<button type="button" role="radio" data-v="target" data-i18n="roleTarget">Cible</button>
|
||||
<button type="button" role="radio" data-v="disabled" data-i18n="roleDisabled">Inactif</button>
|
||||
</div>
|
||||
<label for="cURL">URL</label>
|
||||
<input id="cURL" class="inp mono" required placeholder="https://pve.example.org:8006" autocomplete="off" spellcheck="false">
|
||||
<label for="cToken" data-i18n="cToken">Token</label>
|
||||
<input id="cToken" class="inp mono" required placeholder="confsync@pve!rw" autocomplete="off" spellcheck="false">
|
||||
<label for="cSecret" data-i18n="cSecret">Secret</label>
|
||||
<input id="cSecret" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
|
||||
<p class="muted small hint" id="cSecretHint"></p>
|
||||
<input id="cURL" class="inp mono" required placeholder="https://pve1.example.org:8006" autocomplete="off" spellcheck="false">
|
||||
<label for="cUser" data-i18n="cUser">Utilisateur</label>
|
||||
<input id="cUser" class="inp mono" value="root@pam" readonly tabindex="-1">
|
||||
<label for="cPassword" data-i18n="cPassword">Mot de passe root</label>
|
||||
<input id="cPassword" class="inp mono" type="password" autocomplete="new-password" spellcheck="false">
|
||||
<p class="muted small hint" id="cPasswordHint"></p>
|
||||
<span class="lbl" data-i18n="cTLS">TLS</span>
|
||||
<select id="cTLS" class="field">
|
||||
<option value="fp" data-i18n="tlsFp">Empreinte SHA-256 du certificat</option>
|
||||
<option value="fp" data-i18n="tlsFp">Empreinte du certificat (enregistrée au premier test)</option>
|
||||
<option value="ca" data-i18n="tlsCa">Certificat d'autorité (PEM)</option>
|
||||
<option value="sys" data-i18n="tlsSys">Autorités du système</option>
|
||||
<option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option>
|
||||
</select>
|
||||
<label for="cFp" data-tls="fp" data-i18n="cFp">Empreinte</label>
|
||||
<input id="cFp" class="inp mono" data-tls="fp" placeholder="AA:BB:…" autocomplete="off" spellcheck="false">
|
||||
<input id="cFp" class="inp mono" data-tls="fp" data-i18n-ph="fpPh" autocomplete="off" spellcheck="false">
|
||||
<label for="cCA" data-tls="ca">PEM</label>
|
||||
<textarea id="cCA" class="inp" data-tls="ca" spellcheck="false" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
<label for="cSSHHost" data-i18n="cSSHHost">Nœud SSH</label>
|
||||
<input id="cSSHHost" class="inp mono" data-i18n-ph="sshHostPh" autocomplete="off" spellcheck="false">
|
||||
<label for="cSSHPort" data-i18n="cSSHPort">Port SSH</label>
|
||||
<input id="cSSHPort" class="inp mono" type="number" min="1" max="65535" placeholder="22">
|
||||
<span class="lbl" data-i18n="cSSHKey">Clé d'hôte SSH</span>
|
||||
<span id="cSSHKey" class="mono small muted"></span>
|
||||
<label for="cTimeout" data-i18n="cTimeout">Délai</label>
|
||||
<input id="cTimeout" class="inp mono" placeholder="30s" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div id="cClientOnly">
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="cMapping">Mapping (valeurs propres au client)</h3></div>
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="cMapping">Mapping (valeurs propres à la cible)</h3></div>
|
||||
<p class="muted small" data-i18n="cMappingHelp"></p>
|
||||
<div class="erow-head map"><span data-i18n="colKind">Type</span><span data-i18n="colObject">Objet</span><span data-i18n="colField">Champ</span><span data-i18n="colValue">Valeur</span><span></span></div>
|
||||
<div id="cMapRows" class="erows"></div>
|
||||
<div class="toolbar"><button id="cMapAdd" class="btn tool" type="button" data-i18n="addRow">+ Ajouter</button></div>
|
||||
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="cPolicies">Politiques propres au client</h3></div>
|
||||
<div class="sec-head mt"><h3 class="lbl" data-i18n="cPolicies">Politiques propres à la cible</h3></div>
|
||||
<p class="muted small" data-i18n="cPoliciesHelp"></p>
|
||||
<div class="erow-head pol"><span data-i18n="colKind">Type</span><span data-i18n="colField">Champ</span><span data-i18n="colPolicy">Politique</span><span></span></div>
|
||||
<div id="cPolRows" class="erows"></div>
|
||||
@@ -227,6 +266,7 @@
|
||||
</div>
|
||||
<footer>
|
||||
<button id="cDelete" class="btn danger" type="button" data-i18n="delete">Supprimer</button>
|
||||
<button id="cForget" class="btn ghost" type="button" data-i18n="forget">Oublier les empreintes</button>
|
||||
<span class="sp"></span>
|
||||
<button class="btn ghost" type="button" data-close data-i18n="cancel">Annuler</button>
|
||||
<button class="btn primary" type="submit" data-i18n="save">Enregistrer</button>
|
||||
@@ -234,6 +274,36 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Several clusters at once (administrators) -->
|
||||
<dialog id="bulkDlg" aria-labelledby="bulkTitle">
|
||||
<form class="dlg" id="bulkForm" method="dialog">
|
||||
<header>
|
||||
<h2 id="bulkTitle" data-i18n="bulkTitle">Ajouter une liste de clusters</h2>
|
||||
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
||||
</header>
|
||||
<div class="dlg-body">
|
||||
<p class="muted small" data-i18n="bulkHelp"></p>
|
||||
<textarea id="bulkText" class="inp" spellcheck="false" required placeholder="pve-rennes https://10.0.0.11:8006 pve-nantes https://10.0.1.11:8006 https://pve-brest.lab:8006"></textarea>
|
||||
<div class="field-grid mt-s">
|
||||
<label for="bulkPassword" data-i18n="bulkPassword">Mot de passe root</label>
|
||||
<input id="bulkPassword" class="inp mono" type="password" autocomplete="new-password" required>
|
||||
<span class="lbl" data-i18n="cTLS">TLS</span>
|
||||
<select id="bulkTLS" class="field">
|
||||
<option value="fp" data-i18n="tlsFp">Empreinte du certificat (enregistrée au premier test)</option>
|
||||
<option value="sys" data-i18n="tlsSys">Autorités du système</option>
|
||||
<option value="insecure" data-i18n="tlsInsecure">Sans vérification (labo uniquement)</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul id="bulkRes" class="bulk-res"></ul>
|
||||
</div>
|
||||
<footer>
|
||||
<span class="sp"></span>
|
||||
<button class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
|
||||
<button id="bulkGo" class="btn primary" type="submit" data-i18n="bulkGo">Ajouter et tester</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settingsDlg" class="settings" aria-labelledby="settingsTitle">
|
||||
<div class="dlg">
|
||||
<header>
|
||||
@@ -243,11 +313,6 @@
|
||||
<div class="set-body">
|
||||
<div class="set-side">
|
||||
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
||||
<button type="button" role="tab" data-tab="clusters">
|
||||
<!-- Lucide "server" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01M6 18h.01"/></svg>
|
||||
<span data-i18n="tabClusters">Clusters</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="sync">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||
<span data-i18n="tabSync">Synchronisation</span>
|
||||
@@ -257,11 +322,6 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span data-i18n="tabUsers">Utilisateurs</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="config" data-admin>
|
||||
<!-- Lucide "file-code" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4M10 12l-2 2 2 2M14 12l2 2-2 2"/></svg>
|
||||
<span data-i18n="tabConfig">Import / export</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="account">
|
||||
<!-- Lucide "key-round" (ISC license) -->
|
||||
<svg viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>
|
||||
@@ -283,30 +343,17 @@
|
||||
</div>
|
||||
|
||||
<div class="set-panels">
|
||||
<!-- Clusters -->
|
||||
<div class="set-panel wide" role="tabpanel" data-panel="clusters">
|
||||
<p class="ro-note" data-ro data-i18n="roNote" hidden></p>
|
||||
<section class="set-section">
|
||||
<div class="sec-head">
|
||||
<h3 data-i18n="tabClusters">Clusters</h3>
|
||||
<button id="addCluster" class="btn tool primary" type="button" data-admin-only data-i18n="addCluster">+ Ajouter un cluster</button>
|
||||
</div>
|
||||
<p class="muted small" data-i18n="clustersHelp"></p>
|
||||
<div id="clusterList" class="clist"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Synchronization settings -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="sync" hidden>
|
||||
<div class="set-panel" role="tabpanel" data-panel="sync">
|
||||
<p class="ro-note" data-ro data-i18n="roNote" hidden></p>
|
||||
<section class="set-section" data-edit>
|
||||
<h3 data-i18n="kindsTitle">Types synchronisés</h3>
|
||||
<h3 data-i18n="kindsTitle">Éléments répliqués</h3>
|
||||
<div id="kindChips" class="chip-field"></div>
|
||||
<p class="muted small mt-s" data-i18n="kindsHelp"></p>
|
||||
</section>
|
||||
<section class="set-section danger" data-edit>
|
||||
<h3 data-i18n="pruneTitle">Suppression</h3>
|
||||
<label class="switch-row"><input id="pruneSw" type="checkbox" class="switch"><span data-i18n="pruneLabel">Supprimer chez les clients les objets absents de la référence</span></label>
|
||||
<label class="switch-row"><input id="pruneSw" type="checkbox" class="switch"><span data-i18n="pruneLabel">Supprimer sur les cibles les objets absents du primaire</span></label>
|
||||
<p class="muted small mt-s" data-i18n="pruneHelp"></p>
|
||||
</section>
|
||||
<section class="set-section span" data-edit>
|
||||
@@ -336,31 +383,6 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Import / export -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="config" hidden>
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="exportTitle">Exporter</h3>
|
||||
<p class="muted small" data-i18n="exportHelp"></p>
|
||||
<a id="exportBtn" class="btn" href="api/config/export" download="confsync.yaml">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12M7 10l5 5 5-5M4 17v3h16v-3"/></svg>
|
||||
<span data-i18n="exportBtn">Télécharger confsync.yaml</span>
|
||||
</a>
|
||||
</section>
|
||||
<section class="set-section">
|
||||
<h3 data-i18n="importTitle">Importer</h3>
|
||||
<p class="muted small" data-i18n="importHelp"></p>
|
||||
<textarea id="importText" class="inp" spellcheck="false" placeholder="reference: name: ref …"></textarea>
|
||||
<div class="toolbar">
|
||||
<input id="importFile" type="file" accept=".yaml,.yml,text/yaml" hidden>
|
||||
<button id="importPick" class="btn tool" type="button" data-i18n="importPick">Choisir un fichier…</button>
|
||||
<label class="switch-row"><input id="importReplace" type="checkbox" class="switch"><span data-i18n="importReplace">Supprimer les clusters absents du fichier</span></label>
|
||||
<span class="spacer"></span>
|
||||
<button id="importBtn" class="btn primary" type="button" data-i18n="importBtn">Importer</button>
|
||||
</div>
|
||||
<div id="importRes" class="notes import-res"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="set-panel" role="tabpanel" data-panel="account" hidden>
|
||||
<section class="set-section">
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
</div>
|
||||
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
|
||||
|
||||
<form id="setupForm" class="setup-form" method="post" action="auth/setup" hidden>
|
||||
<h2 data-i18n="setupTitle">Premier démarrage</h2>
|
||||
<p data-i18n="setupIntro">La base est vide : créez le compte administrateur de pve-confsync.</p>
|
||||
<label for="sUser" data-i18n="user">Utilisateur</label>
|
||||
<input id="sUser" name="user" type="text" value="admin" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||
<label for="sPass" data-i18n="pass">Mot de passe</label>
|
||||
<input id="sPass" name="pass" type="password" autocomplete="new-password" minlength="12" required>
|
||||
<label for="sPass2" data-i18n="pass2">Confirmation</label>
|
||||
<input id="sPass2" name="pass2" type="password" autocomplete="new-password" minlength="12" required>
|
||||
<p id="sErr" class="login-err" role="alert" hidden></p>
|
||||
<button class="btn primary" type="submit" data-i18n="setupBtn">Créer le compte et continuer</button>
|
||||
</form>
|
||||
|
||||
<a id="ssoBtn" class="btn primary" href="auth/oidc" hidden data-i18n="sso">Se connecter avec le SSO</a>
|
||||
<div id="sep" class="login-sep" hidden data-i18n="or">ou</div>
|
||||
|
||||
|
||||
+22
-4
@@ -4,17 +4,23 @@
|
||||
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
|
||||
error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre',
|
||||
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.' },
|
||||
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.',
|
||||
setupTitle: 'Premier démarrage', setupIntro: 'La base est vide : créez le compte administrateur de pve-confsync (12 caractères minimum).',
|
||||
pass2: 'Confirmation', setupBtn: 'Créer le compte et continuer',
|
||||
mismatch: 'Les deux mots de passe diffèrent.', invalid: 'Nom ou mot de passe refusé (12 caractères minimum).' },
|
||||
en: { intro: 'Proxmox VE configuration synchronization', user: 'User', pass: 'Password',
|
||||
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
|
||||
error: 'Wrong user or password.', theme: 'Light / dark theme',
|
||||
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.' },
|
||||
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.',
|
||||
setupTitle: 'First start', setupIntro: 'The database is empty: create the administrator account of pve-confsync (12 characters minimum).',
|
||||
pass2: 'Confirm', setupBtn: 'Create the account and continue',
|
||||
mismatch: 'The two passwords differ.', invalid: 'Name or password refused (12 characters minimum).' },
|
||||
};
|
||||
function get(k) { try { return localStorage.getItem('confsync.' + k); } catch (e) { return null; } }
|
||||
function set(k, v) { try { localStorage.setItem('confsync.' + k, v); } catch (e) {} }
|
||||
var lang = get('lang');
|
||||
if (!I18N[lang]) lang = /^en\b/i.test(navigator.language || '') ? 'en' : 'fr';
|
||||
var cfg = { local: true, oidc: false, demo: false };
|
||||
var cfg = { local: true, oidc: false, demo: false, setup: false };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function code(s) { var c = document.createElement('code'); c.textContent = s; return c; }
|
||||
@@ -25,6 +31,9 @@
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(function (e) { e.title = d[e.dataset.i18nTitle]; });
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach(function (e) { e.setAttribute('aria-label', d[e.dataset.i18nAria]); });
|
||||
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
|
||||
var se = q.get('setup');
|
||||
el('sErr').hidden = !se;
|
||||
if (se) el('sErr').textContent = d[se === 'mismatch' ? 'mismatch' : 'invalid'];
|
||||
var h = el('demoHint');
|
||||
h.textContent = '';
|
||||
if (cfg.demo) {
|
||||
@@ -50,9 +59,18 @@
|
||||
el('ret').value = ret;
|
||||
el('err').hidden = q.get('e') !== '1';
|
||||
el('ssoBtn').href = 'auth/oidc?r=' + encodeURIComponent(ret);
|
||||
el('setupForm').addEventListener('submit', function (e) {
|
||||
if (el('sPass').value !== el('sPass2').value) {
|
||||
e.preventDefault();
|
||||
el('sErr').textContent = I18N[lang].mismatch;
|
||||
el('sErr').hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
function show() {
|
||||
el('localForm').hidden = !cfg.local;
|
||||
// empty database: the first administrator is created here
|
||||
el('setupForm').hidden = !cfg.setup;
|
||||
el('localForm').hidden = !cfg.local || cfg.setup;
|
||||
el('ssoBtn').hidden = !cfg.oidc;
|
||||
el('sep').hidden = !(cfg.local && cfg.oidc);
|
||||
el('submitBtn').className = cfg.oidc ? 'btn' : 'btn primary';
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
--k-zone: #1d6fd6;
|
||||
--k-vnet: #1f9aa3;
|
||||
--k-subnet: #5f8a1f;
|
||||
--k-acc: #b45309;
|
||||
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
@@ -65,6 +66,7 @@
|
||||
--k-zone: #5aa2f5;
|
||||
--k-vnet: #3cc3cc;
|
||||
--k-subnet: #9ccc54;
|
||||
--k-acc: #f0a050;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@
|
||||
--k-zone: #5aa2f5;
|
||||
--k-vnet: #3cc3cc;
|
||||
--k-subnet: #9ccc54;
|
||||
--k-acc: #f0a050;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
@@ -362,6 +365,7 @@ button.ccard:hover { background: var(--row-hover); }
|
||||
.kind.sdn-zone { --k: var(--k-zone); }
|
||||
.kind.sdn-vnet { --k: var(--k-vnet); }
|
||||
.kind.sdn-subnet { --k: var(--k-subnet); }
|
||||
.kind.pve-group, .kind.pve-role, .kind.pve-user, .kind.pve-password { --k: var(--k-acc); }
|
||||
|
||||
/* status / mode badges */
|
||||
.badge {
|
||||
@@ -688,3 +692,52 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
|
||||
.mt-s { margin-top: 8px !important; }
|
||||
.erow .acts { display: flex; gap: 2px; }
|
||||
.import-res { margin: 10px 0 0; }
|
||||
|
||||
/* ---------- drop-down menus of the filter bar (replicated kinds, targets) ---------- */
|
||||
.menu { position: relative; }
|
||||
.menu > summary {
|
||||
list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); font-size: 13px; font-weight: 550; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.menu > summary::-webkit-details-marker { display: none; }
|
||||
.menu > summary svg { width: 15px; height: 15px; }
|
||||
.menu > summary .n { font: 600 11px/1 var(--mono); padding: 3px 6px; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
|
||||
.menu[open] > summary { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.menu .pop {
|
||||
position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; min-width: 18rem; max-height: 70vh; overflow-y: auto;
|
||||
padding: 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow);
|
||||
}
|
||||
.menu.right .pop { left: auto; right: 0; }
|
||||
.menu .grp { margin: 6px 4px 2px; font-size: 10.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
|
||||
.menu .grp:first-child { margin-top: 0; }
|
||||
.menu label.opt { display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||
.menu label.opt:hover { background: var(--row-hover); }
|
||||
.menu label.opt input { accent-color: var(--accent); }
|
||||
.menu label.opt .h { margin-left: auto; font-size: 11.5px; color: var(--muted); }
|
||||
.menu .pop .foot { display: flex; gap: 6px; margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border); }
|
||||
.menu .pop .foot .note-s { font-size: 11.5px; color: var(--muted); }
|
||||
|
||||
/* ---------- clusters view ---------- */
|
||||
.cl-view { padding: 12px 20px 20px; }
|
||||
.cl-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||
.cl-tools p { margin: 0; flex: 1 1 320px; }
|
||||
.citem.cl { grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr) max-content; }
|
||||
.citem.cl.primary { border-left-color: var(--accent); background: color-mix(in srgb, var(--accent) 4%, var(--panel)); }
|
||||
.citem.cl.disabled { opacity: .7; }
|
||||
.citem .meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); }
|
||||
.citem .meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.citem .meta b { font-weight: 600; color: var(--text); }
|
||||
.citem .meta .mono { font-size: 11.5px; }
|
||||
.rbadge { font: 700 10px/1.5 var(--mono); padding: 0 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em;
|
||||
color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 40%, transparent); }
|
||||
.rbadge.primary { --c: var(--accent); }
|
||||
.rbadge.target { --c: var(--ok); }
|
||||
.rbadge.disabled { --c: var(--op-same); }
|
||||
.citem select.field { height: 30px; width: auto; }
|
||||
.bulk-res { list-style: none; margin: 10px 0 0; padding: 0; font: 12px var(--mono); }
|
||||
.bulk-res li { padding: 3px 0; border-bottom: 1px dashed var(--border); }
|
||||
.bulk-res li.ok { color: var(--ok); }
|
||||
.bulk-res li.ko { color: var(--err); }
|
||||
.setup-form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||
.setup-form h2 { margin: 0 0 4px; font-size: 15px; text-align: center; }
|
||||
.setup-form p { margin: 0 0 6px; font-size: 12.5px; color: var(--muted); }
|
||||
Reference in new issue
Block a user