diff --git a/web/app.js b/web/app.js index f836933..0622403 100644 --- a/web/app.js +++ b/web/app.js @@ -13,10 +13,7 @@ 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: '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' }, + targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.', setupClusters: 'Base vide : ajoutez vos clusters (URL et mot de passe root), puis choisissez le primaire.', setupClustersBtn: 'Ajouter des clusters', setupPrimary: 'Aucun cluster primaire : choisissez la source de la réplication dans la vue Clusters.', setupPrimaryBtn: 'Choisir le primaire', stripAria: 'Clusters', colKind: 'Type', colObject: 'Objet', colStatus: 'Statut', colFields: 'Champs', @@ -100,8 +97,9 @@ language: 'Langue', themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre', density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte', noRights: 'Votre rôle ne permet pas de répliquer.', errorPrefix: 'Erreur : ', - planKinds: 'Éléments 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.", + planKinds: 'Éléments répliqués', kindOn: 'Répliquer', kindOff: 'Ne pas répliquer', + planKindsHelp: 'Choix enregistré pour toutes les réplications. Les comptes du realm pve vont avec leurs mots de passe et ont besoin de leurs groupes. Changer un choix relance le dry-run.', + planKindsRO: 'Seuls les administrateurs changent les éléments répliqués.', redacted: 'masqué', polDef: { 'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais', @@ -117,10 +115,7 @@ 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: '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' }, + targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.', setupClusters: 'Empty database: add your clusters (URL and root password), then choose the primary.', setupClustersBtn: 'Add clusters', setupPrimary: 'No primary cluster: choose the source of the replication in the Clusters view.', setupPrimaryBtn: 'Choose the primary', stripAria: 'Clusters', colKind: 'Kind', colObject: 'Object', colStatus: 'Status', colFields: 'Fields', @@ -204,8 +199,9 @@ language: 'Language', themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark', density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact', 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.', + planKinds: 'Replicated items', kindOn: 'Replicate', kindOff: 'Do not replicate', + planKindsHelp: 'Choice saved for every replication. The accounts of the pve realm go with their passwords and need their groups. Changing a choice runs the dry-run again.', + planKindsRO: 'Only administrators change the replicated items.', redacted: 'hidden', polDef: { 'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id: sync; the rest: ignore; webauthn, u2f, http_proxy: never', @@ -218,7 +214,6 @@ }, }; 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']], ['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))); @@ -548,7 +543,7 @@ $('#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 === 'diff') { renderDiff(); renderTargetMenu(); } if (S.view === 'clusters') renderClusters(); if (S.view === 'runs') renderRuns(); if (S.view === 'audit') renderAudit(); @@ -802,22 +797,19 @@ } // 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. + // choice per item: it is the saved list of replicated items (administrators), + // and a change runs the dry-run again, so the reviewed plan is the one applied. function renderPlanKinds(sel, plans, intentSync) { const box = $('#planKinds'); - const kinds = (S.settings && S.settings.kinds) || KINDS; - const items = ITEMS.filter((k) => kinds.includes(k)); - box.hidden = sel.scope === 'object' || items.length < 2; + box.hidden = sel.scope === 'object' || !S.settings; if (box.hidden) return; - const skip = new Set(sel.skip || []); + const kinds = S.settings.kinds || KINDS; + const admin = can.admin(); const count = (k) => plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).filter((c) => bundled(k).includes(c.kind)).length, 0); - const rows = items.map((k) => { - const off = 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 rows = ITEMS.map((k) => { + const off = !kinds.includes(k); + const set = async (v) => { + if (await saveKinds(withDeps(kinds, k, v))) openPlan(sel, intentSync); }; const n = off ? '—' : String(count(k)); return h('div', { class: 'pk-row' + (off ? ' off' : '') }, @@ -825,10 +817,23 @@ 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')))); + h('button', { type: 'button', role: 'radio', class: off ? '' : 'on', 'aria-checked': String(!off), disabled: !admin, onclick: () => off && set(true) }, t('kindOn')), + h('button', { type: 'button', role: 'radio', class: off ? 'on' : '', 'aria-checked': String(off), disabled: !admin, onclick: () => !off && set(false) }, t('kindOff')))); }); - box.replaceChildren(h('div', { class: 'pk-head' }, h('strong', { text: t('planKinds') }), h('span', { class: 'muted small', text: t('planKindsHelp') })), ...rows); + box.replaceChildren(h('div', { class: 'pk-head' }, h('strong', { text: t('planKinds') }), h('span', { class: 'muted small', text: t(admin ? 'planKindsHelp' : 'planKindsRO') })), ...rows); + } + // saveKinds saves the list of replicated items (at least one). + async function saveKinds(kinds) { + if (!kinds.length) return false; + const body = Object.assign({}, S.settings, { kinds }); + try { + await api('PUT', 'api/settings', body); + S.settings = body; + return true; + } catch (e) { + if (e.message !== 'auth') toast(e.message, true); + return false; + } } function changeList(changes) { @@ -1506,7 +1511,7 @@ } } - // ---------- menus of the filter bar: replicated items, targets ---------- + // ---------- replicated items, targets of the global actions ---------- // withDeps checks or unchecks a kind with the kinds it needs (or that need it). function withDeps(kinds, k, on) { const out = new Set(kinds); @@ -1515,36 +1520,6 @@ 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 = ITEMS.filter((k) => kinds.includes(k)).length + '/' + ITEMS.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() { @@ -1727,7 +1702,7 @@ const d = await api('GET', 'api/settings'); S.settings = d.settings; S.deps = d.deps || {}; - } catch (e) { /* the menu stays read only */ } + } catch (e) { /* the replicated items stay read only */ } if (S.view === 'clusters') loadClusters(); await loadState(false); if (S.view === 'runs') loadRuns(true); diff --git a/web/index.html b/web/index.html index 89cc294..8225f59 100644 --- a/web/index.html +++ b/web/index.html @@ -67,14 +67,6 @@ Audit - diff --git a/web/style.css b/web/style.css index 29f0add..43a87d6 100644 --- a/web/style.css +++ b/web/style.css @@ -697,7 +697,7 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items: .erow .acts { display: flex; gap: 2px; } .import-res { margin: 10px 0 0; } -/* ---------- drop-down menus of the filter bar (replicated kinds, targets) ---------- */ +/* ---------- drop-down menu of the filter bar (targets) ---------- */ .menu { position: relative; } .menu > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; @@ -746,11 +746,13 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items: .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 */ +/* dry-run: replicated items (replicate / do not replicate) */ .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; } +.plan-kinds .seg button:disabled { cursor: default; } +.plan-kinds .seg button:disabled:not([aria-checked="true"]) { opacity: .55; } @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; } }