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; } }