UI : vocabulaire unifié, comptes + mots de passe, choix par élément, clé AES auto
This commit is contained in:
1 parent
ee8343c69f
commit
2ca2f87307
19 files changed
+377
-143
No files matched your search
+170
-55
@@ -15,7 +15,7 @@
|
||||
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.',
|
||||
kindMenuHelp: 'Les comptes du realm pve sont répliqués avec leurs mots de passe et ont besoin de leurs groupes : 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',
|
||||
@@ -25,25 +25,25 @@
|
||||
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',
|
||||
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', 'pve-password': 'Mots de passe (SSH)',
|
||||
'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': 'mot de passe',
|
||||
'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', 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',
|
||||
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 synchro',
|
||||
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.',
|
||||
@@ -53,28 +53,28 @@
|
||||
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: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
||||
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: 'synchro' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
||||
allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs',
|
||||
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': 'synchro lancée', apply: 'synchro',
|
||||
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: 'Synchronisation', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||||
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: 'inactif' }, makePrimary: 'Définir comme primaire',
|
||||
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: 'Inactif',
|
||||
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",
|
||||
@@ -86,20 +86,31 @@
|
||||
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.',
|
||||
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 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 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 synchroniser.', errorPrefix: 'Erreur : ',
|
||||
noRights: 'Votre rôle ne permet pas de répliquer.', errorPrefix: 'Erreur : ',
|
||||
planKinds: 'Éléments de cette exécution', kindOn: 'Répliquer', kindOff: 'Ne pas répliquer',
|
||||
planKindsHelp: "Un élément exclu n'est pas écrit, sauf ce dont les autres ont besoin (un groupe à créer pour un compte). Changer un choix relance le dry-run.",
|
||||
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',
|
||||
@@ -108,7 +119,7 @@
|
||||
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.',
|
||||
kindMenuHelp: 'The accounts of the pve realm are replicated with their passwords and need their groups: 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',
|
||||
@@ -118,25 +129,25 @@
|
||||
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',
|
||||
applyBtn: 'Replicate', addRow: '+ Add',
|
||||
kinds: {
|
||||
'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
|
||||
'pve-user': 'pve realm accounts', 'pve-password': 'Passwords (SSH)',
|
||||
'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': 'password',
|
||||
'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', 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',
|
||||
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 sync',
|
||||
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.',
|
||||
@@ -146,19 +157,19 @@
|
||||
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: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.',
|
||||
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: 'sync' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
||||
allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs',
|
||||
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': 'sync started', apply: 'sync',
|
||||
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: 'Synchronization', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||||
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.',
|
||||
@@ -179,24 +190,39 @@
|
||||
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.',
|
||||
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 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 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 synchronize.', errorPrefix: 'Error: ',
|
||||
noRights: 'Your role cannot replicate.', errorPrefix: 'Error: ',
|
||||
planKinds: 'Items of this run', kindOn: 'Replicate', kindOff: 'Do not replicate',
|
||||
planKindsHelp: 'An excluded item is not written, except what the others need (a group to create for an account). Changing a choice runs the dry-run again.',
|
||||
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
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 KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user']], ['sdn', ['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'];
|
||||
|
||||
@@ -277,7 +303,64 @@
|
||||
function fmtVal(v) {
|
||||
if (v == null) return '';
|
||||
if (Array.isArray(v)) return '[' + v.join(', ') + ']';
|
||||
return String(v);
|
||||
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'],
|
||||
];
|
||||
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;
|
||||
@@ -317,7 +400,7 @@
|
||||
}
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
const e = new Error(data.error || r.statusText);
|
||||
const e = new Error(tm(data.error || r.statusText));
|
||||
e.status = r.status;
|
||||
e.code = data.code;
|
||||
e.data = data;
|
||||
@@ -497,7 +580,7 @@
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }), h('span', { class: 'c-name', text: r.name }),
|
||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||
h('div', { class: 'c-url', text: r.url }),
|
||||
r.error ? h('div', { class: 'c-err', text: r.error, title: r.error })
|
||||
r.error ? h('div', { class: 'c-err', text: tm(r.error), title: r.error })
|
||||
: 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) {
|
||||
@@ -520,7 +603,7 @@
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||
h('div', { class: 'c-url', text: c.url }),
|
||||
counts,
|
||||
c.error ? h('div', { class: 'c-err', text: c.error, title: c.error }) : null));
|
||||
c.error ? h('div', { class: 'c-err', text: tm(c.error), title: c.error }) : null));
|
||||
}
|
||||
renderClient();
|
||||
}
|
||||
@@ -586,7 +669,7 @@
|
||||
$('#objEmptyHint').textContent = hint;
|
||||
}
|
||||
function note(kind, label, text) {
|
||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text }));
|
||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text: tm(text) }));
|
||||
}
|
||||
|
||||
function match(r) {
|
||||
@@ -606,7 +689,7 @@
|
||||
|
||||
function summary(r) {
|
||||
const s = h('span', { class: 'sum' });
|
||||
if (r.status === 'error') { s.textContent = (r.errors || [])[0] || ''; s.title = s.textContent; return s; }
|
||||
if (r.status === 'error') { s.textContent = tm((r.errors || [])[0] || ''); s.title = s.textContent; return s; }
|
||||
const ch = (r.fields || []).filter((f) => f.change);
|
||||
if (!ch.length || r.status === 'delete') {
|
||||
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
||||
@@ -648,7 +731,7 @@
|
||||
|
||||
function details(r) {
|
||||
const d = h('div', { class: 'details' });
|
||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.join('\n') }));
|
||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.map(tm).join('\n') }));
|
||||
const tb = h('tbody');
|
||||
for (const f of r.fields || []) {
|
||||
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
||||
@@ -699,6 +782,7 @@
|
||||
try {
|
||||
const d = await api('POST', '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);
|
||||
@@ -717,6 +801,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
// renderPlanKinds shows, above the plan, one "replicate / do not replicate"
|
||||
// choice per replicated item: a change runs the dry-run again without the
|
||||
// items left out, so the reviewed plan is the one applied.
|
||||
function renderPlanKinds(sel, plans, intentSync) {
|
||||
const box = $('#planKinds');
|
||||
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
||||
const items = ITEMS.filter((k) => kinds.includes(k));
|
||||
box.hidden = sel.scope === 'object' || items.length < 2;
|
||||
if (box.hidden) return;
|
||||
const skip = new Set(sel.skip || []);
|
||||
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 = skip.has(k);
|
||||
const set = (v) => {
|
||||
const next = new Set(skip);
|
||||
bundled(k).forEach((x) => (v ? next.delete(x) : next.add(x)));
|
||||
openPlan(Object.assign({}, sel, { skip: KINDS.filter((x) => next.has(x)) }), 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), onclick: () => off && set(true) }, t('kindOn')),
|
||||
h('button', { type: 'button', role: 'radio', class: off ? 'on' : '', 'aria-checked': String(off), 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('planKindsHelp') })), ...rows);
|
||||
}
|
||||
|
||||
function changeList(changes) {
|
||||
const ul = h('ul', { class: 'plan' });
|
||||
for (const ch of changes || []) {
|
||||
@@ -786,7 +900,7 @@
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||
if (!d.run) { toast(d.summary || t('planEmpty')); return; }
|
||||
if (!d.run) { toast(tm(d.summary) || t('planEmpty')); return; }
|
||||
plan.run = d.run;
|
||||
$('#planApply').hidden = true;
|
||||
$('#planTitle').textContent = t('applying');
|
||||
@@ -819,7 +933,7 @@
|
||||
if (run.status !== 'running') {
|
||||
clearInterval(plan.poll);
|
||||
$('#planTitle').textContent = t('applyDone') + ' — ' + t('res.' + run.status);
|
||||
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + run.summary;
|
||||
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + tm(run.summary);
|
||||
loadState(true);
|
||||
if (S.view === 'runs') loadRuns(true);
|
||||
}
|
||||
@@ -887,7 +1001,7 @@
|
||||
h('span', { class: 'txt', text: t('scope.' + r.scope) }),
|
||||
h('span', { class: 'txt mono', text: (r.clients || []).join(', '), title: (r.objects || []).join(', ') }),
|
||||
h('span', null, h('span', { class: 'badge ' + r.status, text: t('res.' + r.status) })),
|
||||
h('span', { class: 'txt', text: r.summary, title: r.summary }));
|
||||
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);
|
||||
@@ -991,7 +1105,7 @@
|
||||
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 : '') });
|
||||
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + tm(k.error) : '') });
|
||||
}
|
||||
function renderClusters() {
|
||||
const list = $('#clusterList');
|
||||
@@ -1056,7 +1170,7 @@
|
||||
if (r.sshFingerprint && !r.sshMismatch) pins.push('SSH ' + r.sshFingerprint);
|
||||
if (!r.ok) {
|
||||
el.className = 'chk ko';
|
||||
el.replaceChildren('✗ ' + r.error);
|
||||
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); } }));
|
||||
@@ -1065,7 +1179,7 @@
|
||||
}
|
||||
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(' ; ') : '');
|
||||
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.map(tm).join(' ; ') : '');
|
||||
return true;
|
||||
}
|
||||
async function testAll() {
|
||||
@@ -1306,7 +1420,7 @@
|
||||
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));
|
||||
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);
|
||||
@@ -1322,7 +1436,6 @@
|
||||
try {
|
||||
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) {
|
||||
@@ -1335,7 +1448,7 @@
|
||||
function renderSettings() {
|
||||
const st = set.settings;
|
||||
const chips = $('#kindChips');
|
||||
chips.replaceChildren(...KINDS.map((k) => h('button', {
|
||||
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'));
|
||||
@@ -1345,14 +1458,14 @@
|
||||
}, t('kinds.' + k))));
|
||||
$('#pruneSw').checked = !!st.prune;
|
||||
const sel = $('#selRows');
|
||||
sel.replaceChildren(...KINDS.map((k) => {
|
||||
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(...KINDS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), set.defaults[k] || '')));
|
||||
$('#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 || {})) {
|
||||
@@ -1374,8 +1487,10 @@
|
||||
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: $$('#kindChips .chip.on').map((c) => c.dataset.kind),
|
||||
kinds: KINDS.filter((k) => $$('#kindChips .chip.on').some((c) => bundled(c.dataset.kind).includes(k))),
|
||||
objects,
|
||||
policies: readPolicies($('#polRows')),
|
||||
prune: $('#pruneSw').checked,
|
||||
@@ -1395,15 +1510,15 @@
|
||||
// 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 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 drop(k);
|
||||
if (on) need(k); else bundled(k).forEach(drop);
|
||||
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;
|
||||
$('#kindMenuN').textContent = ITEMS.filter((k) => kinds.includes(k)).length + '/' + ITEMS.length;
|
||||
const admin = can.admin();
|
||||
pop.replaceChildren();
|
||||
for (const [g, ks] of KIND_GROUPS) {
|
||||
|
||||
+4
-3
@@ -96,7 +96,7 @@
|
||||
</button>
|
||||
<button id="syncAllBtn" class="btn tool sync" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><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="syncAll">Tout synchroniser</span>
|
||||
<span data-i18n="syncAll">Répliquer</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -191,6 +191,7 @@
|
||||
<h2 id="planTitle"></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 id="planKinds" class="plan-kinds" hidden></div>
|
||||
<div id="planBody" class="dlg-body"></div>
|
||||
<footer>
|
||||
<span id="planNote" class="muted small"></span>
|
||||
@@ -198,7 +199,7 @@
|
||||
<button id="planClose" class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
|
||||
<button id="planApply" class="btn primary" type="button" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><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="applyBtn">Synchroniser</span>
|
||||
<span data-i18n="applyBtn">Répliquer</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -315,7 +316,7 @@
|
||||
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
||||
<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>
|
||||
<span data-i18n="tabSync">Réplication</span>
|
||||
</button>
|
||||
<button type="button" role="tab" data-tab="users" data-admin>
|
||||
<!-- Lucide "users" (ISC license) -->
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
|
||||
<span>pve-confsync</span>
|
||||
</div>
|
||||
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
|
||||
<p class="muted" data-i18n="intro">Réplication 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>
|
||||
|
||||
+2
-2
@@ -1,14 +1,14 @@
|
||||
// Login page: language, theme, local form and/or OIDC button (from /auth/config).
|
||||
(function () {
|
||||
var I18N = {
|
||||
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||
fr: { intro: 'Réplication 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}.',
|
||||
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',
|
||||
en: { intro: 'Proxmox VE configuration replication', 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}.',
|
||||
|
||||
@@ -741,3 +741,12 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
|
||||
.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); }
|
||||
|
||||
/* dry-run: replicate / do not replicate, per item */
|
||||
.plan-kinds { display: grid; gap: 6px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--panel-2); }
|
||||
.plan-kinds .pk-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-bottom: 2px; }
|
||||
.plan-kinds .pk-row { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr) 2.5rem auto; align-items: center; gap: 10px; }
|
||||
.plan-kinds .pk-row.off .pk-name, .plan-kinds .pk-row.off .kind { opacity: .55; }
|
||||
.plan-kinds .pk-n { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.plan-kinds .seg button { padding: 3px 10px; font-size: 12px; }
|
||||
@media (max-width: 560px) { .plan-kinds .pk-row { grid-template-columns: minmax(0, 1fr) auto; } .plan-kinds .pk-row .kind, .plan-kinds .pk-n { display: none; } }
|
||||
Reference in new issue
Block a user