Réplication de nœuds : réseau et DNS par l'API, multipath, chrony, sysctl par SSH
Deuxième mode de réplication, séparé de la réplication cluster vers cluster
(sélecteur « Clusters (API) | Nœuds (fichiers) » en haut de page) : un profil
réplique la configuration système d'un nœud maître vers des nœuds cibles.
- réseau par /nodes/{node}/network (interfaces.new puis rechargement), adresses
et passerelles propres à chaque nœud, mapping des noms d'interfaces par cible ;
DNS par /nodes/{node}/dns ; multipath.conf, wwids (ajout seul), chrony,
sysctl.d, interfaces.d (hors sdn) par SSH
- découverte des nœuds (/cluster/status), accès SSH par nœud (mot de passe du
cluster ou propre), clé d'hôte épinglée, contrôle du nom du nœud
- dry-run avec diff des fichiers ; apply (admin) avec contrôle SHA-256,
sauvegarde et restore.sh sur le nœud, écriture atomique, rechargements,
restauration si un rechargement échoue, timer systemd de restauration si le
nœud est injoignable après le rechargement du réseau, confirmation tapée
- vue Nœuds et profils, journal séparé par mode, démo avec nœuds simulés
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dc1a7ff40e
commit
1e667ce8b9
24 files changed
+4973
-65
No files matched your search
+604
-50
@@ -213,6 +213,85 @@
|
||||
},
|
||||
},
|
||||
};
|
||||
// node replication: the second mode (system files of the nodes)
|
||||
Object.assign(I18N.fr, {
|
||||
modeAriaSw: 'Type de réplication', modeCluster: 'Clusters (API)', modeNode: 'Nœuds (fichiers)',
|
||||
modeClusterTitle: "Réplication cluster vers cluster par l'API : options datacenter, comptes, SDN",
|
||||
modeNodeTitle: 'Réplication nœud vers nœud : réseau et DNS (API), multipath, chrony, sysctl (SSH)',
|
||||
viewNodes: 'Nœuds et profils', profileAria: 'Profil de réplication', nreference: 'MAÎTRE', thMaster: 'Maître',
|
||||
nodesHelp: "Un profil réplique la configuration système d'un nœud maître vers des nœuds cibles, du même cluster ou d'un autre. Le réseau et le DNS passent par l'API PVE, les fichiers par SSH en root sur chaque nœud (mot de passe du cluster, ou propre au nœud). La clé d'hôte SSH de chaque nœud est enregistrée au premier test. Le maître n'est jamais modifié.",
|
||||
addProfile: '+ Nouveau profil', discover: 'Découvrir les nœuds', profilesTitle: 'Profils de réplication', nodesTitle: 'Nœuds',
|
||||
noProfiles: 'Aucun profil.', noNodes: "Aucun nœud connu : « Découvrir les nœuds » lit les nœuds de chaque cluster (adresse dans le cluster).",
|
||||
setupNodes: 'Aucun profil de réplication de nœuds : découvrez les nœuds, puis créez un profil (nœud maître, cibles, éléments).', setupNodesBtn: 'Créer un profil',
|
||||
nodeHelp: "Adresse SSH du nœud : vide, son adresse dans le cluster. Le mot de passe root est celui du cluster, sauf s'il en a un propre. Changer d'adresse oublie la clé d'hôte enregistrée.",
|
||||
nHost: 'Adresse SSH', nPassword: 'Mot de passe root', nClear: 'Utiliser le mot de passe du cluster', forgetKey: 'Oublier la clé',
|
||||
editNode: 'Nœud {n}', nodePwdKeep: 'Vide : mot de passe inchangé ({w}).', pwdCluster: 'celui du cluster', pwdOwn: 'propre au nœud',
|
||||
newProfile: 'Nouveau profil', editProfile: 'Profil {p}', pMaster: 'Nœud maître', pTargets: 'Nœuds cibles', pItems: 'Éléments répliqués',
|
||||
pItemsHelp: "Réseau et DNS : API PVE (interfaces.new puis rechargement). Les autres : SSH en root, sauvegarde du fichier sur le nœud, écriture atomique, rechargement (sysctl --system, systemctl restart chrony, multipathd reconfigure, ifreload -a). wwids : ajout seul. Jamais répliqués : /etc/pve, corosync, hostname, clés SSH, interfaces.d/sdn (généré par le SDN).",
|
||||
pPrune: "Supprimer sur les cibles ce que le maître n'a pas", pPruneHelp: 'Désactivé par défaut. Activé : interfaces (jamais les interfaces physiques) et fichiers sysctl.d, chrony sources.d, interfaces.d absents du maître.',
|
||||
pPolHelp: "sync : copie du maître ; ignore : jamais écrit ; override : écrit seulement depuis le mapping de la cible. Par défaut, les adresses et passerelles (cidr, cidr6, gateway, gateway6) sont en override : chaque nœud garde les siennes.",
|
||||
pProtect: 'Lignes protégées des fichiers', pProtectHelp: "Expressions régulières, une par ligne : une ligne du maître qui correspond prend la valeur de la ligne de la cible qui correspond (supprimée si la cible n'en a pas).",
|
||||
pMapping: 'Mapping par cible', pMappingHelp: "Interfaces : noms du maître et de la cible quand ils diffèrent (eno1=enp65s0f0, séparés par des virgules ; les VLAN eno1.100 suivent). Valeurs : une par ligne, « interface champ=valeur » (vmbr1 cidr=10.5.0.12/24) ou « dns champ=valeur ».",
|
||||
mapNics: 'Interfaces (maître=cible)', mapValues: 'Valeurs propres',
|
||||
confirmDeleteProfile: 'Supprimer le profil {p} ?', targetsN: '{n} cible(s)', discovered: '{n} nœud(s) trouvé(s)', clusterRoleN: 'cluster {r}',
|
||||
netConfirm: "Ces changements rechargent le réseau de {c} (ifreload -a). S'il n'est plus joignable ensuite, le nœud restaure seul sa configuration précédente au bout de 120 s. Tapez « {w} » pour confirmer.",
|
||||
netConfirmGlobal: "Ces changements rechargent le réseau de plusieurs nœuds, l'un après l'autre (arrêt au premier échec). Tapez le nom du profil « {w} » pour confirmer.",
|
||||
netBadge: 'RÉSEAU', reloadsLine: 'Puis : {l}', fileSame: 'Fichier identique.', fileExtra: 'Fichier présent seulement sur la cible (conservé).',
|
||||
nodeStats: 'Profil {p} · maître {ref} · {n} cible(s) · {d} avec écarts · lu à {t}',
|
||||
noTargetN: 'Aucune cible dans ce profil.', noTargetNHint: 'Ajoutez des nœuds cibles au profil dans la vue Nœuds et profils.',
|
||||
emptyOkHintN: 'La cible est conforme au maître pour les éléments du profil.',
|
||||
syncAdminOnly: 'Seuls les administrateurs répliquent vers les nœuds.',
|
||||
});
|
||||
Object.assign(I18N.en, {
|
||||
modeAriaSw: 'Replication kind', modeCluster: 'Clusters (API)', modeNode: 'Nodes (files)',
|
||||
modeClusterTitle: 'Cluster to cluster replication through the API: datacenter options, accounts, SDN',
|
||||
modeNodeTitle: 'Node to node replication: network and DNS (API), multipath, chrony, sysctl (SSH)',
|
||||
viewNodes: 'Nodes and profiles', profileAria: 'Replication profile', nreference: 'MASTER', thMaster: 'Master',
|
||||
nodesHelp: 'A profile replicates the system configuration of a master node to target nodes, of the same cluster or another one. The network and the DNS go through the PVE API, the files through SSH as root on each node (password of the cluster, or its own). The SSH host key of each node is recorded on the first test. The master is never written.',
|
||||
addProfile: '+ New profile', discover: 'Discover the nodes', profilesTitle: 'Replication profiles', nodesTitle: 'Nodes',
|
||||
noProfiles: 'No profile.', noNodes: 'No known node: "Discover the nodes" reads the nodes of every cluster (address in the cluster).',
|
||||
setupNodes: 'No node replication profile: discover the nodes, then create a profile (master node, targets, items).', setupNodesBtn: 'Create a profile',
|
||||
nodeHelp: 'SSH address of the node: empty, its address in the cluster. The root password is the one of the cluster, unless it has its own. A new address forgets the recorded host key.',
|
||||
nHost: 'SSH address', nPassword: 'Root password', nClear: 'Use the password of the cluster', forgetKey: 'Forget the key',
|
||||
editNode: 'Node {n}', nodePwdKeep: 'Empty: password unchanged ({w}).', pwdCluster: 'the cluster one', pwdOwn: 'own password',
|
||||
newProfile: 'New profile', editProfile: 'Profile {p}', pMaster: 'Master node', pTargets: 'Target nodes', pItems: 'Replicated items',
|
||||
pItemsHelp: 'Network and DNS: PVE API (interfaces.new then reload). The others: SSH as root, backup of the file on the node, atomic write, reload (sysctl --system, systemctl restart chrony, multipathd reconfigure, ifreload -a). wwids: add only. Never replicated: /etc/pve, corosync, hostname, SSH keys, interfaces.d/sdn (generated by the SDN).',
|
||||
pPrune: 'Delete on the targets what the master does not have', pPruneHelp: 'Off by default. On: interfaces (never the physical ones) and the files of sysctl.d, chrony sources.d, interfaces.d absent from the master.',
|
||||
pPolHelp: 'sync: copied from the master; ignore: never written; override: only written from the target mapping. By default the addresses and gateways (cidr, cidr6, gateway, gateway6) are override: each node keeps its own.',
|
||||
pProtect: 'Protected lines of the files', pProtectHelp: 'Regular expressions, one per line: a line of the master that matches takes the value of the matching line of the target (dropped when the target has none).',
|
||||
pMapping: 'Mapping per target', pMappingHelp: 'Interfaces: names on the master and on the target when they differ (eno1=enp65s0f0, comma separated; VLANs eno1.100 follow). Values: one per line, "interface field=value" (vmbr1 cidr=10.5.0.12/24) or "dns field=value".',
|
||||
mapNics: 'Interfaces (master=target)', mapValues: 'Own values',
|
||||
confirmDeleteProfile: 'Delete profile {p}?', targetsN: '{n} target(s)', discovered: '{n} node(s) found', clusterRoleN: 'cluster {r}',
|
||||
netConfirm: 'These changes reload the network of {c} (ifreload -a). If it is unreachable afterwards, the node restores its previous configuration by itself after 120 s. Type "{w}" to confirm.',
|
||||
netConfirmGlobal: 'These changes reload the network of several nodes, one after the other (stop at the first failure). Type the name of the profile "{w}" to confirm.',
|
||||
netBadge: 'NETWORK', reloadsLine: 'Then: {l}', fileSame: 'Same file.', fileExtra: 'File only on the target (kept).',
|
||||
nodeStats: 'Profile {p} · master {ref} · {n} target(s) · {d} with differences · read at {t}',
|
||||
noTargetN: 'No target in this profile.', noTargetNHint: 'Add target nodes to the profile in the Nodes and profiles view.',
|
||||
emptyOkHintN: 'The target is in line with the master for the items of the profile.',
|
||||
syncAdminOnly: 'Only administrators replicate to the nodes.',
|
||||
});
|
||||
const NODE_KINDS = {
|
||||
fr: [{ network: 'Réseau (API)', dns: 'DNS (API)', multipath: 'multipath.conf', wwids: 'wwids multipath', chrony: 'chrony', sysctl: 'sysctl.d', 'interfaces-d': 'interfaces.d' },
|
||||
{ network: 'réseau', dns: 'dns', multipath: 'multipath', wwids: 'wwids', chrony: 'chrony', sysctl: 'sysctl', 'interfaces-d': 'if.d' }],
|
||||
en: [{ network: 'Network (API)', dns: 'DNS (API)', multipath: 'multipath.conf', wwids: 'multipath wwids', chrony: 'chrony', sysctl: 'sysctl.d', 'interfaces-d': 'interfaces.d' },
|
||||
{ network: 'network', dns: 'dns', multipath: 'multipath', wwids: 'wwids', chrony: 'chrony', sysctl: 'sysctl', 'interfaces-d': 'if.d' }],
|
||||
};
|
||||
for (const l of ['fr', 'en']) {
|
||||
Object.assign(I18N[l].kinds, NODE_KINDS[l][0]);
|
||||
Object.assign(I18N[l].kindShort, NODE_KINDS[l][1]);
|
||||
}
|
||||
Object.assign(I18N.fr.action, { 'nodes-discover': 'découverte des nœuds', 'node-save': 'nœud enregistré', 'node-check': 'test du nœud',
|
||||
'node-forget': 'clé oubliée', 'profile-save': 'profil enregistré', 'profile-delete': 'profil supprimé', 'node-dry-run': 'dry-run nœuds',
|
||||
'node-apply-start': 'réplication nœuds lancée', 'node-apply': 'réplication nœuds' });
|
||||
Object.assign(I18N.en.action, { 'nodes-discover': 'node discovery', 'node-save': 'node saved', 'node-check': 'node test',
|
||||
'node-forget': 'key forgotten', 'profile-save': 'profile saved', 'profile-delete': 'profile deleted', 'node-dry-run': 'node dry-run',
|
||||
'node-apply-start': 'node replication started', 'node-apply': 'node replication' });
|
||||
Object.assign(I18N.fr.scope, { nodes: 'nœuds', node: 'nœud', 'node-object': 'objet de nœud' });
|
||||
Object.assign(I18N.en.scope, { nodes: 'nodes', node: 'node', 'node-object': 'node object' });
|
||||
I18N.fr.res.skipped = 'non lancé';
|
||||
I18N.en.res.skipped = 'not run';
|
||||
const NITEMS = ['network', 'dns', 'multipath', 'wwids', 'chrony', 'sysctl', 'interfaces-d'];
|
||||
|
||||
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||||
// kinds chosen as one item: the accounts of the pve realm with their passwords
|
||||
const BUNDLES = { 'pve-user': ['pve-password'] };
|
||||
@@ -350,6 +429,29 @@
|
||||
[/SDN configuration committed but reload failed/, 'configuration SDN validée mais rechargement en échec'],
|
||||
[/root@pam cannot be deleted/, 'root@pam ne peut pas être supprimé'],
|
||||
[/\bconnection refused\b/, 'connexion refusée'],
|
||||
[/physical interface (\S+) absent on (\S+): add it to the NIC mapping of the target/, 'interface physique $1 absente de $2 : ajoutez-la au mapping des interfaces de la cible'],
|
||||
[/type differs \((\S+) on the target, (\S+) on the master\): re-create the interface by hand/, 'type différent ($1 sur la cible, $2 sur le maître) : recréez l\'interface à la main'],
|
||||
[/options the API does not write differ \((.*)\): not replicated/, "options que l'API n'écrit pas différentes ($1) : non répliquées"],
|
||||
[/pending network changes on (\S+) \(interfaces.new\): apply or revert them in PVE first/, 'changements réseau en attente sur $1 (interfaces.new) : appliquez-les ou annulez-les d\'abord dans PVE'],
|
||||
[/the master has pending network changes: its pending configuration is replicated/, 'le maître a des changements réseau en attente : sa configuration en attente est répliquée'],
|
||||
[/a search domain is required by the API.*/, "un domaine de recherche est obligatoire pour l'API (PUT /nodes/{node}/dns)"],
|
||||
[/^(\S+) absent on the master: not replicated$/, '$1 absent du maître : non répliqué'],
|
||||
[/: (\d+) bytes, too large, not replicated/, ' : $1 octets, trop gros, non répliqué'],
|
||||
[/SSH host key not recorded yet, test the node first/, "clé d'hôte SSH pas encore enregistrée, testez d'abord le nœud"],
|
||||
[/node runs PVE "([^"]*)", only (\d+)\.x is supported/, 'le nœud est en PVE « $1 », seul $2.x est pris en charge'],
|
||||
[/unknown node (\S+) \(discover the nodes of its cluster\)/, 'nœud $1 inconnu (découvrez les nœuds de son cluster)'],
|
||||
[/changed since the dry-run: (\S+)/, 'modifié depuis le dry-run : $1'],
|
||||
[/unreachable after the network reload: it restores its previous configuration by itself within (\S+)/, 'injoignable après le rechargement du réseau : il restaure seul sa configuration précédente sous $1'],
|
||||
[/the node is unreachable: it restores its previous configuration by itself within (\S+)/, 'le nœud est injoignable : il restaure seul sa configuration précédente sous $1'],
|
||||
[/^network changes: confirmation required \((.*)\)$/, 'changements réseau : confirmation requise ($1)'],
|
||||
[/answers as "([^"]*)", not as node (\S+)/, 'répond en tant que « $1 », pas en tant que nœud $2'],
|
||||
[/^select at least one item$/, 'cochez au moins un élément'],
|
||||
[/^master node is required$/, 'le nœud maître est obligatoire'],
|
||||
[/^name is required$/, 'le nom est obligatoire'],
|
||||
[/target (\S+) is the master/, 'la cible $1 est le maître'],
|
||||
[/^profile "(.*)" already exists$/, 'le profil « $1 » existe déjà'],
|
||||
[/restore failed, run sh (\S+) on the node/, 'restauration en échec, lancez sh $1 sur le nœud'],
|
||||
[/^unknown profile$/, 'profil inconnu'],
|
||||
];
|
||||
function tm(msg) {
|
||||
if (lang !== 'fr' || !msg) return msg;
|
||||
@@ -410,16 +512,21 @@
|
||||
}
|
||||
|
||||
// ---------- application state ----------
|
||||
// the state of the differences view is kept per replication mode: S.state,
|
||||
// S.client and S.skip are those of the current mode (getters below)
|
||||
const MODES = {
|
||||
cluster: { state: null, client: null, skip: new Set(JSON.parse(store.get('skipTargets') || '[]')), skipKey: 'skipTargets' },
|
||||
node: { state: null, client: null, skip: new Set(JSON.parse(store.get('skipNodes') || '[]')), skipKey: 'skipNodes' },
|
||||
};
|
||||
const S = {
|
||||
me: null,
|
||||
state: null, // last comparison
|
||||
setup: '', // 'clusters' (empty database) or 'primary' (no primary chosen)
|
||||
mode: store.get('mode') === 'node' ? 'node' : 'cluster',
|
||||
nodes: [], profiles: [], profile: store.get('profile') || '', // node replication
|
||||
setup: '', // 'clusters' (empty database), 'primary' (no primary chosen) or 'nodes' (no profile)
|
||||
clusters: [], // clusters view
|
||||
settings: null, // synchronization settings (replicated kinds)
|
||||
deps: {}, // kind -> kinds it needs
|
||||
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)
|
||||
kind: '',
|
||||
status: store.get('status') || 'diff',
|
||||
@@ -427,9 +534,15 @@
|
||||
runs: [], runsEnd: false, runOpen: new Set(), runMode: '',
|
||||
events: [], eventsEnd: false, eventOpen: new Set(),
|
||||
};
|
||||
for (const k of ['state', 'client', 'skip']) {
|
||||
Object.defineProperty(S, k, { get: () => MODES[S.mode][k], set: (v) => { MODES[S.mode][k] = v; } });
|
||||
}
|
||||
const isNode = () => S.mode === 'node';
|
||||
const can = {
|
||||
sync: () => S.me && (S.me.role === 'admin' || S.me.role === 'operator'),
|
||||
admin: () => S.me && S.me.role === 'admin',
|
||||
// writing system files as root on the nodes: administrators only
|
||||
apply: () => (isNode() ? can.admin() : can.sync()),
|
||||
};
|
||||
|
||||
// ---------- language, theme, density ----------
|
||||
@@ -445,7 +558,9 @@
|
||||
}
|
||||
function fillSelects() {
|
||||
const ks = $('#kindSel');
|
||||
ks.replaceChildren(h('option', { value: '', text: t('allKinds') }), ...KINDS.map((k) => h('option', { value: k, text: t('kinds.' + k) })));
|
||||
const kinds = isNode() ? NITEMS : KINDS;
|
||||
if (!kinds.includes(S.kind)) S.kind = '';
|
||||
ks.replaceChildren(h('option', { value: '', text: t('allKinds') }), ...kinds.map((k) => h('option', { value: k, text: t('kinds.' + k) })));
|
||||
ks.value = S.kind;
|
||||
ks.classList.toggle('set', !!S.kind);
|
||||
const ss = $('#statusSel');
|
||||
@@ -456,6 +571,13 @@
|
||||
const ms = $('#runModeSel');
|
||||
ms.replaceChildren(...[['', 'allModes'], ['apply', 'onlyApply'], ['dry-run', 'onlyDry']].map(([v, k]) => h('option', { value: v, text: t(k) })));
|
||||
ms.value = S.runMode;
|
||||
fillProfiles();
|
||||
}
|
||||
function fillProfiles() {
|
||||
const ps = $('#profileSel');
|
||||
ps.replaceChildren(...S.profiles.map((p) => h('option', { value: p.name, text: p.name })));
|
||||
if (!S.profiles.some((p) => p.name === S.profile)) S.profile = S.profiles.length ? S.profiles[0].name : '';
|
||||
ps.value = S.profile;
|
||||
}
|
||||
function themeOpt() { return store.get('theme') || 'system'; }
|
||||
function setTheme(v) {
|
||||
@@ -471,15 +593,26 @@
|
||||
$$('#densitySwitch [data-density]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.density === (v || 'normal'))));
|
||||
}
|
||||
|
||||
// ---------- routing (#diff/<target>, #clusters, #runs, #audit) ----------
|
||||
// ---------- routing (#diff/<target>, #clusters, #runs, #audit; node mode:
|
||||
// #node/diff/<target>, #node/nodes, #node/runs) ----------
|
||||
function readHash() {
|
||||
const [v, c] = decodeURIComponent(location.hash.slice(1)).split('/');
|
||||
S.view = ['diff', 'clusters', 'runs', 'audit'].includes(v) ? v : 'diff';
|
||||
const parts = decodeURIComponent(location.hash.slice(1)).split('/');
|
||||
if (parts[0] === 'node') {
|
||||
S.mode = 'node';
|
||||
parts.shift();
|
||||
} else if (parts[0]) {
|
||||
S.mode = 'cluster';
|
||||
}
|
||||
const v = parts[0];
|
||||
const views = isNode() ? ['diff', 'nodes', 'runs', 'audit'] : ['diff', 'clusters', 'runs', 'audit'];
|
||||
S.view = views.includes(v) ? v : 'diff';
|
||||
if (S.view === 'audit' && !can.admin()) S.view = 'diff';
|
||||
// node keys hold a slash: cluster/node
|
||||
const c = parts.slice(1).join('/');
|
||||
if (c) S.client = c;
|
||||
}
|
||||
function writeHash() {
|
||||
const hsh = '#' + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client) : '');
|
||||
const hsh = '#' + (isNode() ? 'node/' : '') + S.view + (S.view === 'diff' && S.client ? '/' + encodeURIComponent(S.client).replace(/%2F/g, '/') : '');
|
||||
if (location.hash !== hsh) history.replaceState(null, '', hsh);
|
||||
}
|
||||
function setView(v) {
|
||||
@@ -489,10 +622,27 @@
|
||||
if (v === 'runs') loadRuns(true);
|
||||
if (v === 'audit') loadAudit(true);
|
||||
if (v === 'clusters') loadClusters();
|
||||
if (v === 'nodes') loadNodes();
|
||||
}
|
||||
// setMode switches between the two replications; each keeps its own view state.
|
||||
function setMode(m) {
|
||||
if (m === S.mode) return;
|
||||
S.mode = m;
|
||||
store.set('mode', m === 'node' ? 'node' : null);
|
||||
S.open.clear();
|
||||
if (S.view === 'clusters' || S.view === 'nodes') S.view = m === 'node' ? 'nodes' : 'clusters';
|
||||
fillSelects();
|
||||
writeHash();
|
||||
renderAll();
|
||||
if (S.view === 'nodes') loadNodes();
|
||||
if (S.view === 'clusters') loadClusters();
|
||||
if (S.view === 'runs') renderRuns();
|
||||
if (!S.state) loadState(false);
|
||||
}
|
||||
|
||||
// ---------- loading ----------
|
||||
async function loadState(refresh) {
|
||||
if (isNode()) return loadNodeState(refresh);
|
||||
busy(true);
|
||||
$('#refreshBtn').classList.add('spin');
|
||||
try {
|
||||
@@ -517,6 +667,39 @@
|
||||
}
|
||||
renderAll();
|
||||
}
|
||||
// loadNodeState reads the profiles, then the comparison of the selected one.
|
||||
async function loadNodeState(refresh) {
|
||||
const mode = MODES.node;
|
||||
busy(true);
|
||||
$('#refreshBtn').classList.add('spin');
|
||||
try {
|
||||
const d = await api('GET', 'api/nodes');
|
||||
S.nodes = d.nodes;
|
||||
S.profiles = d.profiles;
|
||||
S.nodeItems = d.items;
|
||||
S.nodeDefaults = d.defaults || {};
|
||||
fillProfiles();
|
||||
$('#error').hidden = true;
|
||||
if (!S.profile) {
|
||||
mode.state = null;
|
||||
} else {
|
||||
const r = await api('GET', 'api/nodestate?profile=' + encodeURIComponent(S.profile) + (refresh ? '&refresh=1' : ''));
|
||||
mode.state = r.state;
|
||||
const names = r.state.clients.map((c) => c.name);
|
||||
if (!names.includes(mode.client)) {
|
||||
const first = r.state.clients.find((c) => c.error || (c.plan && ((c.plan.changes || []).length || (c.plan.errors || []).length)));
|
||||
mode.client = first ? first.name : names[0] || null;
|
||||
}
|
||||
}
|
||||
if (isNode()) writeHash();
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') showError(e.message);
|
||||
} finally {
|
||||
busy(false);
|
||||
$('#refreshBtn').classList.remove('spin');
|
||||
}
|
||||
if (isNode()) renderAll();
|
||||
}
|
||||
function showError(msg) {
|
||||
const el = $('#error');
|
||||
el.textContent = msg;
|
||||
@@ -529,22 +712,31 @@
|
||||
renderStatus();
|
||||
}
|
||||
function renderView() {
|
||||
document.body.classList.toggle('mode-node', isNode());
|
||||
$$('#modeSw [data-mode]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.mode === S.mode)));
|
||||
$('#viewMode [data-view="clusters"]').hidden = isNode();
|
||||
$('#viewMode [data-view="nodes"]').hidden = !isNode();
|
||||
$$('#viewMode [data-view]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.view === S.view)));
|
||||
$('#diffView').hidden = S.view !== 'diff';
|
||||
$('#clustersView').hidden = S.view !== 'clusters';
|
||||
$('#nodesView').hidden = S.view !== 'nodes';
|
||||
$('#runsView').hidden = S.view !== 'runs';
|
||||
$('#auditView').hidden = S.view !== 'audit';
|
||||
$$('[data-diff]').forEach((e) => { e.hidden = S.view !== 'diff'; });
|
||||
$$('[data-runs]').forEach((e) => { e.hidden = S.view !== 'runs'; });
|
||||
$$('[data-node-only]').forEach((e) => { e.hidden = !isNode() || (e.matches('[data-diff]') && S.view !== 'diff') || (e.id === 'profileSel' && !S.profiles.length); });
|
||||
$('#globalActs').hidden = S.view !== 'diff' || !S.state || !can.sync();
|
||||
$('#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');
|
||||
const setup = isNode() ? (S.profiles.length || !S.me ? '' : 'nodes') : S.setup;
|
||||
$('#setup').hidden = !setup || S.view === 'clusters' || S.view === 'nodes';
|
||||
if (setup) {
|
||||
const k = { clusters: ['setupClusters', 'setupClustersBtn'], primary: ['setupPrimary', 'setupPrimaryBtn'], nodes: ['setupNodes', 'setupNodesBtn'] }[setup];
|
||||
$('#setupText').textContent = t(k[0]);
|
||||
$('#setupBtn').textContent = t(k[1]);
|
||||
}
|
||||
$$('[data-admin-only]').forEach((e) => { e.hidden = !can.admin(); });
|
||||
if (S.view === 'diff') { renderDiff(); renderTargetMenu(); }
|
||||
if (S.view === 'clusters') renderClusters();
|
||||
if (S.view === 'nodes') renderNodes();
|
||||
if (S.view === 'runs') renderRuns();
|
||||
if (S.view === 'audit') renderAudit();
|
||||
}
|
||||
@@ -572,12 +764,12 @@
|
||||
// reference card
|
||||
const r = st.reference;
|
||||
strip.appendChild(h('div', { class: 'ccard ref' },
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }),
|
||||
r.error ? h('span', { class: 'c-role st st-error', text: t('cst.error') }) : null, h('span', { class: 'c-name', text: r.name }),
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t(isNode() ? 'nreference' : 'reference') }),
|
||||
r.error ? h('span', { class: 'c-role st st-error', text: t('cst.error') }) : null, h('span', { class: 'c-name', text: r.node || r.name, title: r.name }),
|
||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||
h('div', { class: 'c-url', text: r.url }),
|
||||
h('div', { class: 'c-url', text: r.url || (r.cluster ? 'cluster ' + r.cluster : '') }),
|
||||
r.error ? h('div', { class: 'c-err', text: tm(r.error), title: r.error })
|
||||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
|
||||
: h('div', { class: 'c-counts' }, r.objects != null ? h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }) : null,
|
||||
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
|
||||
for (const c of st.clients) {
|
||||
const cs = clientStatus(c);
|
||||
@@ -594,8 +786,8 @@
|
||||
type: 'button', role: 'tab', class: 'ccard', 'aria-selected': String(c.name === S.client),
|
||||
onclick: () => { S.client = c.name; S.open.clear(); writeHash(); renderDiff(); },
|
||||
},
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role st st-' + cs, text: t('cst.' + cs) }), h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||
h('div', { class: 'c-url', text: c.url }),
|
||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role st st-' + cs, text: t('cst.' + cs) }), h('span', { class: 'c-name', text: c.node || c.name, title: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||
h('div', { class: 'c-url', text: c.url || (c.cluster ? 'cluster ' + c.cluster : '') }),
|
||||
counts,
|
||||
c.error ? h('div', { class: 'c-err', text: tm(c.error), title: c.error }) : null));
|
||||
}
|
||||
@@ -617,7 +809,7 @@
|
||||
if (!c) {
|
||||
bar.hidden = true;
|
||||
$('#objTable').hidden = true;
|
||||
emptyState(t('noClient'), t('noClientHint'));
|
||||
emptyState(t(isNode() ? 'noTargetN' : 'noClient'), t(isNode() ? 'noTargetNHint' : 'noClientHint'));
|
||||
$('#count').textContent = '';
|
||||
return;
|
||||
}
|
||||
@@ -635,11 +827,13 @@
|
||||
can.sync() ? h('div', { class: 'acts' },
|
||||
h('button', { type: 'button', class: 'btn tool', disabled: !!c.error || !!st.reference.error, onclick: () => openPlan({ scope: 'client', client: c.name }) },
|
||||
icon('scan'), t('dryClient')),
|
||||
h('button', { type: 'button', class: 'btn tool sync', disabled: blocked || nChanges === 0, onclick: () => openPlan({ scope: 'client', client: c.name }, true) },
|
||||
h('button', { type: 'button', class: 'btn tool sync', disabled: blocked || nChanges === 0 || !can.apply(), title: can.apply() ? null : t('syncAdminOnly'),
|
||||
onclick: () => openPlan({ scope: 'client', client: c.name }, true) },
|
||||
icon('sync'), t('syncClient'))) : null);
|
||||
if (c.error) notes.appendChild(note('err', t('clientError'), c.error));
|
||||
for (const e of p.errors || []) notes.appendChild(note('err', t('planError'), e));
|
||||
for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), w));
|
||||
for (const e of p.errors || []) notes.appendChild(note('err', t('planError'), issue(e)));
|
||||
for (const w of p.warnings || []) notes.appendChild(note('warn', t('warning'), issue(w)));
|
||||
for (const w of c.warnings || []) if (isNode()) notes.appendChild(note('warn', t('warning'), w));
|
||||
|
||||
const rows = (c.rows || []).filter(match);
|
||||
$('#count').textContent = (c.rows || []).length ? rows.length + ' / ' + c.rows.length : '';
|
||||
@@ -650,7 +844,7 @@
|
||||
if (c.error) { $('#objEmpty').hidden = true; return; }
|
||||
const filtered = (c.rows || []).length && (S.kind || S.q || S.status !== 'diff');
|
||||
if (filtered) emptyState(t('emptyFilter'), t('emptyFilterHint'));
|
||||
else emptyState(t('emptyOk'), t('emptyOkHint'));
|
||||
else emptyState(t('emptyOk'), t(isNode() ? 'emptyOkHintN' : 'emptyOkHint'));
|
||||
return;
|
||||
}
|
||||
$('#objEmpty').hidden = true;
|
||||
@@ -662,6 +856,11 @@
|
||||
$('#objEmptyTitle').textContent = title;
|
||||
$('#objEmptyHint').textContent = hint;
|
||||
}
|
||||
// issue: the plans of the node replication give {key, message}, the cluster ones strings
|
||||
function issue(e) {
|
||||
if (e == null || typeof e === 'string') return e;
|
||||
return (e.key ? e.key.replace(/^[^/]+\//, '') + ' : ' : '') + e.message;
|
||||
}
|
||||
function note(kind, label, text) {
|
||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text: tm(text) }));
|
||||
}
|
||||
@@ -675,7 +874,8 @@
|
||||
}
|
||||
if (S.q) {
|
||||
const q = S.q.toLowerCase();
|
||||
const hay = [r.id, r.kind, ...(r.fields || []).flatMap((f) => [f.name, fmtVal(f.ref), fmtVal(f.current), fmtVal(f.mapping)])].join('\n').toLowerCase();
|
||||
const hay = [r.id, r.kind, ...(r.fields || []).flatMap((f) => [f.name, fmtVal(f.ref), fmtVal(f.current), fmtVal(f.mapping)]),
|
||||
...(r.diff || []).map((l) => l.text)].join('\n').toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
@@ -684,6 +884,15 @@
|
||||
function summary(r) {
|
||||
const s = h('span', { class: 'sum' });
|
||||
if (r.status === 'error') { s.textContent = tm((r.errors || [])[0] || ''); s.title = s.textContent; return s; }
|
||||
if (r.diff && r.diff.length) {
|
||||
const add = r.diff.filter((l) => l.op === '+').length;
|
||||
const del = r.diff.filter((l) => l.op === '-').length;
|
||||
if (add) s.appendChild(h('span', { class: 'f-add', text: '+' + add }));
|
||||
if (add && del) s.appendChild(document.createTextNode(' '));
|
||||
if (del) s.appendChild(h('span', { class: 'f-unset', text: '−' + del }));
|
||||
return s;
|
||||
}
|
||||
if (!(r.fields || []).length && isNode()) { s.textContent = t(r.status === 'extra' ? 'fileExtra' : 'fileSame'); return s; }
|
||||
const ch = (r.fields || []).filter((f) => f.change);
|
||||
if (!ch.length || r.status === 'delete') {
|
||||
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
||||
@@ -713,7 +922,7 @@
|
||||
summary(r),
|
||||
h('span', { class: 'acts' }, can.sync() && actionable && !c.error ? [
|
||||
iconBtn('scan', t('dryObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] })),
|
||||
iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go'),
|
||||
can.apply() ? iconBtn('sync', t('syncObj'), () => openPlan({ scope: 'object', client: c.name, objects: [r.key] }, true), 'go') : null,
|
||||
] : null));
|
||||
function toggle() {
|
||||
if (S.open.has(key)) S.open.delete(key); else S.open.add(key);
|
||||
@@ -723,9 +932,24 @@
|
||||
return row;
|
||||
}
|
||||
|
||||
// udiff renders the unified diff of a file
|
||||
function udiff(lines) {
|
||||
return h('div', { class: 'udiff' }, ...lines.map((l) => h('div', {
|
||||
class: l.op === '+' ? 'd-add' : l.op === '-' ? 'd-del' : l.op === '@' ? 'd-hunk' : '',
|
||||
text: l.op === '@' ? l.text : l.op + l.text,
|
||||
})));
|
||||
}
|
||||
function details(r) {
|
||||
const d = h('div', { class: 'details' });
|
||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.map(tm).join('\n') }));
|
||||
if (r.diff && r.diff.length) {
|
||||
d.appendChild(udiff(r.diff));
|
||||
return d;
|
||||
}
|
||||
if (isNode() && !(r.fields || []).length) {
|
||||
d.appendChild(h('p', { class: 'muted', text: t(r.status === 'extra' ? 'fileExtra' : 'fileSame') }));
|
||||
return d;
|
||||
}
|
||||
const tb = h('tbody');
|
||||
for (const f of r.fields || []) {
|
||||
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
||||
@@ -740,7 +964,7 @@
|
||||
return d;
|
||||
}
|
||||
d.appendChild(h('table', { class: 'fields' },
|
||||
h('thead', null, h('tr', null, ...['thField', 'thPolicy', 'thRef', 'thMapping', 'thCurrent', 'thTarget'].map((k) => h('th', { text: t(k) })))),
|
||||
h('thead', null, h('tr', null, ...['thField', 'thPolicy', isNode() ? 'thMaster' : 'thRef', 'thMapping', 'thCurrent', 'thTarget'].map((k) => h('th', { text: t(k) })))),
|
||||
tb));
|
||||
return d;
|
||||
}
|
||||
@@ -750,11 +974,12 @@
|
||||
const s = $('#stats');
|
||||
if (!st) { s.textContent = ''; return; }
|
||||
const d = st.clients.filter((c) => clientStatus(c) !== 'ok').length;
|
||||
s.textContent = t('statsLine', { ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) });
|
||||
s.textContent = isNode() ? t('nodeStats', { p: st.profile, ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) })
|
||||
: t('statsLine', { ref: st.reference.name, n: st.clients.length, d, t: fmtTime(st.at) });
|
||||
}
|
||||
|
||||
// ---------- dry-run, confirmation, synchronization ----------
|
||||
const plan = { sel: null, plans: null, run: 0, poll: 0 };
|
||||
const plan = { sel: null, plans: null, run: 0, poll: 0, node: false };
|
||||
|
||||
function whatLabel(sel) {
|
||||
if (sel.scope === 'global') return (sel.targets || []).length ? t('whatTargets', { l: sel.targets.join(', ') }) : t('whatGlobal');
|
||||
@@ -763,6 +988,9 @@
|
||||
}
|
||||
|
||||
async function openPlan(sel, intentSync) {
|
||||
// a plan stays in the mode it was opened in (node: profile and endpoints)
|
||||
if (sel.profile === undefined && isNode()) sel = Object.assign({ profile: S.profile }, sel);
|
||||
plan.node = sel.profile !== undefined;
|
||||
plan.sel = sel;
|
||||
plan.plans = null;
|
||||
clearInterval(plan.poll);
|
||||
@@ -774,7 +1002,7 @@
|
||||
if (!dlg.open) dlg.showModal();
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/dryrun', sel);
|
||||
const d = await api('POST', plan.node ? 'api/nodedryrun' : 'api/dryrun', sel);
|
||||
plan.plans = d.plans;
|
||||
renderPlanKinds(sel, d.plans, intentSync);
|
||||
renderPlans(d.plans);
|
||||
@@ -782,7 +1010,7 @@
|
||||
const total = d.plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).length, 0);
|
||||
const blocked = d.plans.some((p) => p.error || ((p.plan && p.plan.errors) || []).length);
|
||||
const ap = $('#planApply');
|
||||
ap.hidden = !can.sync() || total === 0 || blocked;
|
||||
ap.hidden = !(plan.node ? can.admin() : can.sync()) || total === 0 || blocked;
|
||||
ap.disabled = false;
|
||||
if (!ap.hidden && intentSync) ap.focus();
|
||||
// the dry-run read the clusters again: show what it read
|
||||
@@ -800,7 +1028,7 @@
|
||||
// and a change runs the dry-run again, so the reviewed plan is the one applied.
|
||||
function renderPlanKinds(sel, plans, intentSync) {
|
||||
const box = $('#planKinds');
|
||||
box.hidden = sel.scope === 'object' || !S.settings;
|
||||
box.hidden = sel.scope === 'object' || !S.settings || plan.node;
|
||||
if (box.hidden) return;
|
||||
const kinds = S.settings.kinds || KINDS;
|
||||
const admin = can.admin();
|
||||
@@ -850,8 +1078,9 @@
|
||||
else pf.appendChild(h('li', { class: 'add' }, '+ ' + k + ' = ', h('ins', { text: fmtVal(set[k]) })));
|
||||
}
|
||||
for (const k of ch.unset || []) pf.appendChild(h('li', { class: 'unset', text: '− ' + k + ' (' + t('was') + ' ' + fmtVal(old[k]) + ')' }));
|
||||
if (ch.op === 'delete') pf.appendChild(h('li', { class: 'unset', text: Object.keys(old).sort().map((k) => k + '=' + fmtVal(old[k])).join(', ') }));
|
||||
if (ch.op === 'delete' && !ch.diff) pf.appendChild(h('li', { class: 'unset', text: Object.keys(old).sort().map((k) => k + '=' + fmtVal(old[k])).join(', ') }));
|
||||
if (pf.children.length) li.appendChild(pf);
|
||||
if (ch.diff && ch.diff.length) li.appendChild(udiff(ch.diff));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
@@ -867,26 +1096,29 @@
|
||||
h('h4', null, p.client,
|
||||
h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'),
|
||||
text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }),
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null),
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null,
|
||||
p.network ? h('span', { class: 'badge network', text: t('netBadge') }) : null),
|
||||
p.error ? note('err', t('clientError'), p.error) : null,
|
||||
errs.length ? h('div', { class: 'notes' }, note('err', t('planError'), t('planErrors')), ...errs.map((e) => note('err', '', e))) : null,
|
||||
((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), w))) : null,
|
||||
changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null)));
|
||||
errs.length ? h('div', { class: 'notes' }, note('err', t('planError'), t('planErrors')), ...errs.map((e) => note('err', '', issue(e)))) : null,
|
||||
((p.plan && p.plan.warnings) || []).length ? h('div', { class: 'notes' }, ...p.plan.warnings.map((w) => note('warn', t('warning'), issue(w)))) : null,
|
||||
changes.length ? changeList(changes) : (!p.error && !errs.length ? h('p', { class: 'muted small', text: t('planEmpty') }) : null),
|
||||
changes.length && (p.reloads || []).length ? h('p', { class: 'plan reloads', text: t('reloadsLine', { l: p.reloads.join(' ; ') }) }) : null));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlan() {
|
||||
const sel = plan.sel;
|
||||
const plans = plan.plans || [];
|
||||
const sdn = plans.some((p) => p.sdn && ((p.plan && p.plan.changes) || []).length);
|
||||
const want = sel.scope === 'global' ? S.state.reference.name : sel.client;
|
||||
const sdn = plans.some((p) => (p.sdn || p.network) && ((p.plan && p.plan.changes) || []).length);
|
||||
const want = sel.scope === 'global' ? (plan.node ? sel.profile : MODES.cluster.state.reference.name) : sel.client;
|
||||
let confirmText = '';
|
||||
if (sdn) {
|
||||
let box = $('#planBody .confirm-box');
|
||||
if (!box) {
|
||||
const inp = h('input', { class: 'inp mono', autocomplete: 'off', spellcheck: 'false', 'aria-label': want });
|
||||
box = h('div', { class: 'confirm-box' },
|
||||
h('span', { text: sel.scope === 'global' ? t('sdnConfirmGlobal', { w: want }) : t('sdnConfirm', { c: sel.client, w: want }) }), inp);
|
||||
h('span', { text: plan.node ? (sel.scope === 'global' ? t('netConfirmGlobal', { w: want }) : t('netConfirm', { c: sel.client, w: want }))
|
||||
: sel.scope === 'global' ? t('sdnConfirmGlobal', { w: want }) : t('sdnConfirm', { c: sel.client, w: want }) }), inp);
|
||||
$('#planBody').appendChild(box);
|
||||
$('#planApply').disabled = true;
|
||||
inp.addEventListener('input', () => { $('#planApply').disabled = inp.value !== want; });
|
||||
@@ -903,7 +1135,7 @@
|
||||
$('#planApply').disabled = true;
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||
const d = await api('POST', plan.node ? 'api/nodeapply' : 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||
if (!d.run) { toast(tm(d.summary) || t('planEmpty')); return; }
|
||||
plan.run = d.run;
|
||||
$('#planApply').hidden = true;
|
||||
@@ -956,16 +1188,18 @@
|
||||
res ? h('span', { class: 'badge ' + res.status, text: t('res.' + res.status) })
|
||||
: h('span', { class: 'badge ' + (p.error || errs.length ? 'error' : changes.length ? 'update' : 'same'),
|
||||
text: p.error || errs.length ? t('st.error') : changes.length ? t('changesN', { n: changes.length }) : t('inLine') }),
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null));
|
||||
p.sdn ? h('span', { class: 'badge sdn', text: t('sdnBadge') }) : null,
|
||||
p.network ? h('span', { class: 'badge network', text: t('netBadge') }) : null));
|
||||
if (p.error) box.appendChild(note('err', t('clientError'), p.error));
|
||||
for (const e of errs) box.appendChild(note('err', t('planError'), e));
|
||||
for (const e of errs) box.appendChild(note('err', t('planError'), issue(e)));
|
||||
if (changes.length) box.appendChild(changeList(changes));
|
||||
if (res && res.steps && res.steps.length) {
|
||||
box.appendChild(h('ul', { class: 'steps' }, ...res.steps.map((s) => h('li', null,
|
||||
h('time', { text: fmtTime(s.at) }), h('span', { class: s.error ? 'ko' : 'ok', text: s.error ? '✗' : '✓' }),
|
||||
h('span', { text: s.action }), s.error ? h('span', { class: 'e', text: s.error }) : null))));
|
||||
h('span', { text: s.action }), s.error ? h('span', { class: 'e', text: tm(s.error) }) : null))));
|
||||
}
|
||||
if (res && res.error) box.appendChild(note('err', t('st.error'), res.error));
|
||||
if (res && res.status === 'skipped') box.appendChild(h('p', { class: 'muted small', text: t('res.skipped') }));
|
||||
wrap.appendChild(box);
|
||||
}
|
||||
return wrap;
|
||||
@@ -990,7 +1224,8 @@
|
||||
function renderRuns() {
|
||||
const list = $('#runList');
|
||||
list.replaceChildren();
|
||||
const runs = S.runs.filter((r) => !S.runMode || r.mode === S.runMode)
|
||||
const runs = S.runs.filter((r) => (r.scope || '').startsWith('node') === isNode())
|
||||
.filter((r) => !S.runMode || r.mode === S.runMode)
|
||||
.filter((r) => !S.q || [r.user, r.summary, ...(r.clients || []), ...(r.objects || [])].join(' ').toLowerCase().includes(S.q.toLowerCase()));
|
||||
$('#count').textContent = S.runs.length ? String(runs.length) : '';
|
||||
if (!runs.length) list.appendChild(h('div', { class: 'row', role: 'presentation' }, h('span', { class: 'muted txt', text: t('noRuns') })));
|
||||
@@ -1248,14 +1483,14 @@
|
||||
return s;
|
||||
}
|
||||
function refIds(kind) {
|
||||
const st = S.state;
|
||||
const st = MODES.cluster.state;
|
||||
if (!st) return [];
|
||||
const ids = new Set();
|
||||
for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) ids.add(r.id);
|
||||
return Array.from(ids).sort();
|
||||
}
|
||||
function knownFields(kind) {
|
||||
const st = S.state;
|
||||
const st = MODES.cluster.state;
|
||||
const f = new Set();
|
||||
if (st) for (const c of st.clients) for (const r of c.rows || []) if (r.kind === kind) for (const x of r.fields || []) f.add(x.name);
|
||||
if (kind === 'sdn-zone') ['bridge', 'nodes', 'peers', 'fabric', 'dns', 'reversedns', 'dnszone', 'mtu'].forEach((x) => f.add(x));
|
||||
@@ -1543,13 +1778,15 @@
|
||||
}
|
||||
}
|
||||
function saveSkip() {
|
||||
store.set('skipTargets', S.skip.size ? JSON.stringify(Array.from(S.skip)) : null);
|
||||
store.set(MODES[S.mode].skipKey, S.skip.size ? JSON.stringify(Array.from(S.skip)) : null);
|
||||
renderTargetMenu();
|
||||
}
|
||||
function globalSel() {
|
||||
const names = S.state ? S.state.clients.map((c) => c.name) : [];
|
||||
const sel = selectedTargets();
|
||||
return { scope: 'global', targets: sel.length === names.length ? [] : sel };
|
||||
const g = { scope: 'global', targets: sel.length === names.length ? [] : sel };
|
||||
if (isNode()) g.profile = S.profile;
|
||||
return g;
|
||||
}
|
||||
|
||||
// users
|
||||
@@ -1608,11 +1845,327 @@
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
// ---------- nodes and profiles view (node replication) ----------
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const d = await api('GET', 'api/nodes');
|
||||
S.nodes = d.nodes;
|
||||
S.profiles = d.profiles;
|
||||
S.nodeItems = d.items;
|
||||
S.nodeDefaults = d.defaults || {};
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
return;
|
||||
}
|
||||
fillProfiles();
|
||||
renderView();
|
||||
}
|
||||
function renderNodes() {
|
||||
const admin = can.admin();
|
||||
const pl = $('#profileList');
|
||||
pl.replaceChildren();
|
||||
if (!S.profiles.length) pl.appendChild(h('p', { class: 'muted', text: t('noProfiles') }));
|
||||
for (const p of S.profiles) {
|
||||
pl.appendChild(h('div', { class: 'citem prof' + (p.name === S.profile ? ' ref' : '') },
|
||||
h('div', { class: 'n' }, h('span', { text: p.name })),
|
||||
h('div', { class: 'u', text: p.master + ' → ' + ((p.targets || []).join(', ') || '—'), title: (p.targets || []).join(', ') }),
|
||||
h('div', { class: 'acts' },
|
||||
h('button', { type: 'button', class: 'btn tool', onclick: () => { S.profile = p.name; store.set('profile', p.name); MODES.node.state = null; setView('diff'); loadState(false); } },
|
||||
icon('scan'), t('viewDiff')),
|
||||
admin ? h('button', { type: 'button', class: 'btn tool', onclick: () => editProfile(p) }, icon('pencil'), t('edit')) : null),
|
||||
h('div', { class: 'items' }, ...(p.items || []).map((k) => h('span', { class: 'kind ' + k, text: t('kinds.' + k) })),
|
||||
h('span', { class: 'muted small', text: t('targetsN', { n: (p.targets || []).length }) + (p.prune ? ' · prune' : '') }))));
|
||||
}
|
||||
const nl = $('#nodeList');
|
||||
nl.replaceChildren();
|
||||
if (!S.nodes.length) nl.appendChild(h('p', { class: 'muted', text: t('noNodes') }));
|
||||
for (const n of S.nodes) {
|
||||
const chk = h('div', { class: 'chk', hidden: true });
|
||||
const meta = h('div', { class: 'meta' }, checkLine(n));
|
||||
if (admin && n.sshFingerprint) meta.appendChild(h('span', { class: 'mono', title: n.sshFingerprint, text: 'SSH ' + n.sshFingerprint }));
|
||||
if (admin && n.hasPassword) meta.appendChild(h('span', { text: t('pwdOwn') }));
|
||||
const addr = admin ? 'ssh root@' + (n.sshHost || n.ip) + (n.sshPort && n.sshPort !== 22 ? ':' + n.sshPort : '') : '';
|
||||
nl.appendChild(h('div', { class: 'citem node cl ' + (n.clusterRole || '') },
|
||||
h('div', { class: 'n' }, h('span', { class: 'rbadge ' + n.clusterRole, text: n.cluster }), h('span', { text: n.node })),
|
||||
h('div', { class: 'u', text: addr, title: addr }),
|
||||
admin ? h('div', { class: 'acts' },
|
||||
h('button', { type: 'button', class: 'btn tool', onclick: () => checkNode(n, chk) }, icon('plug'), t('test')),
|
||||
h('button', { type: 'button', class: 'btn tool', onclick: () => editNode(n) }, icon('pencil'), t('edit'))) : h('span'),
|
||||
meta, chk));
|
||||
}
|
||||
}
|
||||
const nodePath = (n) => 'api/nodes/' + encodeURIComponent(n.cluster) + '/' + encodeURIComponent(n.node);
|
||||
async function checkNode(n, el, trust) {
|
||||
if (el) { el.hidden = false; el.className = 'chk muted'; el.textContent = t('testing'); }
|
||||
let r;
|
||||
try {
|
||||
r = await api('POST', nodePath(n) + '/check', trust ? { trust: true } : undefined);
|
||||
} catch (e) {
|
||||
if (el) { el.className = 'chk ko'; el.textContent = '✗ ' + e.message; }
|
||||
return false;
|
||||
}
|
||||
await loadNodes();
|
||||
const row = $$('#nodeList .citem').find((x) => $('.n span:first-child', x).textContent === n.cluster && $('.n span:last-child', x).textContent === n.node);
|
||||
el = row ? $('.chk', row) : null;
|
||||
if (!el) return r.ok;
|
||||
el.hidden = false;
|
||||
if (!r.ok) {
|
||||
el.className = 'chk ko';
|
||||
el.replaceChildren('✗ ' + tm(r.error));
|
||||
if (r.sshMismatch) {
|
||||
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
||||
onclick: () => { if (confirm(t('confirmTrust', { c: n.key, fp: r.sshFingerprint }))) checkNode(n, el, true); } }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
el.className = 'chk ok';
|
||||
el.textContent = '✓ ' + t('testOk') + (r.version ? ' · PVE ' + r.version : '') + (r.sshFingerprint ? ' — ' + t('pinned', { l: 'SSH ' + r.sshFingerprint }) : '');
|
||||
return true;
|
||||
}
|
||||
async function testNodes() {
|
||||
for (const n of S.nodes) {
|
||||
const row = $$('#nodeList .citem').find((x) => $('.n span:first-child', x).textContent === n.cluster && $('.n span:last-child', x).textContent === n.node);
|
||||
await checkNode(n, row ? $('.chk', row) : null);
|
||||
}
|
||||
}
|
||||
async function discoverNodes() {
|
||||
busy(true);
|
||||
try {
|
||||
const d = await api('POST', 'api/nodes/discover');
|
||||
const errs = Object.entries(d.errors || {});
|
||||
toast(t('discovered', { n: d.found }) + (errs.length ? ' — ' + errs.map(([c, e]) => c + ' : ' + tm(e)).join(' ; ') : ''), errs.length > 0);
|
||||
} catch (e) {
|
||||
if (e.message !== 'auth') toast(e.message, true);
|
||||
} finally {
|
||||
busy(false);
|
||||
}
|
||||
loadNodes();
|
||||
}
|
||||
|
||||
let editingNode = null;
|
||||
function editNode(n) {
|
||||
editingNode = n;
|
||||
$('#nodeTitle').textContent = t('editNode', { n: n.key });
|
||||
$('#nHost').value = n.sshHost || '';
|
||||
$('#nHost').placeholder = n.ip || '';
|
||||
$('#nPort').value = n.sshPort && n.sshPort !== 22 ? n.sshPort : '';
|
||||
$('#nPassword').value = '';
|
||||
$('#nPassword').placeholder = t('nodePwdKeep', { w: t(n.hasPassword ? 'pwdOwn' : 'pwdCluster') });
|
||||
$('#nClear').checked = !n.hasPassword;
|
||||
$('#nKey').textContent = n.sshFingerprint || t('sshKeyNone');
|
||||
$('#nForget').hidden = !n.sshFingerprint;
|
||||
$('#nErr').hidden = true;
|
||||
$('#nodeDlg').showModal();
|
||||
$('#nHost').focus();
|
||||
}
|
||||
async function saveNode(ev) {
|
||||
ev.preventDefault();
|
||||
const n = editingNode;
|
||||
const pwd = $('#nPassword').value;
|
||||
try {
|
||||
await api('PUT', nodePath(n), { sshHost: $('#nHost').value.trim(), sshPort: Number($('#nPort').value) || 22,
|
||||
password: pwd, clearPassword: !pwd && $('#nClear').checked && n.hasPassword });
|
||||
$('#nodeDlg').close();
|
||||
toast(t('saved'));
|
||||
await loadNodes();
|
||||
checkNode(n, null);
|
||||
} catch (e) {
|
||||
$('#nErr').textContent = e.message;
|
||||
$('#nErr').hidden = false;
|
||||
}
|
||||
}
|
||||
async function forgetNode() {
|
||||
const n = editingNode;
|
||||
if (!n || !confirm(t('confirmForget', { c: n.key }))) return;
|
||||
try {
|
||||
await api('POST', nodePath(n) + '/forget');
|
||||
$('#nodeDlg').close();
|
||||
loadNodes();
|
||||
} catch (e) {
|
||||
$('#nErr').textContent = e.message;
|
||||
$('#nErr').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// profile editor
|
||||
let editingProfile = null;
|
||||
function editProfile(p) {
|
||||
editingProfile = p || null;
|
||||
p = p || { name: '', master: '', targets: [], items: ['network', 'dns'], prune: false, policies: {}, protect: {}, mapping: {} };
|
||||
$('#profileTitle').textContent = editingProfile ? t('editProfile', { p: p.name }) : t('newProfile');
|
||||
$('#pName').value = p.name;
|
||||
const keys = S.nodes.map((n) => n.key);
|
||||
if (p.master && !keys.includes(p.master)) keys.unshift(p.master);
|
||||
$('#pMaster').replaceChildren(h('option', { value: '', text: '—' }), ...keys.map((k) => h('option', { value: k, text: k })));
|
||||
$('#pMaster').value = p.master;
|
||||
renderTargetChips(p.targets || []);
|
||||
$('#pItems').replaceChildren(...NITEMS.map((k) => chip(k, t('kinds.' + k), (p.items || []).includes(k))));
|
||||
$('#pPrune').checked = !!p.prune;
|
||||
const pr = $('#pPolRows');
|
||||
pr.replaceChildren();
|
||||
for (const [k, fields] of Object.entries(p.policies || {})) for (const [f, x] of Object.entries(fields || {})) pr.appendChild(nodePolRow(k, f, x));
|
||||
$('#pProtect').replaceChildren(...NITEMS.filter((k) => k !== 'network' && k !== 'dns').map((k) => h('div', { class: 'pprot', dataset: { item: k } },
|
||||
h('label', { text: t('kinds.' + k) }),
|
||||
h('textarea', { class: 'inp', spellcheck: 'false', placeholder: k === 'chrony' ? '^server .* iburst$' : '^# host ' }, ((p.protect || {})[k] || []).join('\n')))));
|
||||
renderMapRows(p);
|
||||
$('#pDelete').hidden = !editingProfile;
|
||||
$('#pErr').hidden = true;
|
||||
$('#profileDlg').showModal();
|
||||
$('#pName').focus();
|
||||
}
|
||||
function chip(value, label, on, onclick) {
|
||||
return h('button', { type: 'button', class: 'chip' + (on ? ' on' : ''), dataset: { v: value }, 'aria-pressed': String(on),
|
||||
onclick: (ev) => {
|
||||
const b = ev.currentTarget;
|
||||
b.classList.toggle('on');
|
||||
b.setAttribute('aria-pressed', String(b.classList.contains('on')));
|
||||
if (onclick) onclick();
|
||||
} }, label);
|
||||
}
|
||||
const chipsOn = (sel) => $$(sel + ' .chip.on').map((c) => c.dataset.v);
|
||||
function renderTargetChips(targets) {
|
||||
const master = $('#pMaster').value;
|
||||
const keys = S.nodes.map((n) => n.key).filter((k) => k !== master);
|
||||
for (const x of targets) if (!keys.includes(x) && x !== master) keys.push(x);
|
||||
$('#pTargets').replaceChildren(...keys.map((k) => chip(k, k, targets.includes(k), () => renderMapRows(readProfile()))));
|
||||
}
|
||||
function nodePolRow(item, field, pol) {
|
||||
const ks = h('select', { class: 'field' }, ...['network', 'dns'].map((k) => h('option', { value: k, text: t('kindShort.' + k) })));
|
||||
ks.value = item || 'network';
|
||||
const fieldsOf = (k) => k === 'dns' ? ['search', 'dns1', 'dns2', 'dns3']
|
||||
: ['autostart', 'bond-primary', 'bond_mode', 'bond_xmit_hash_policy', 'bridge_ports', 'bridge_vids', 'bridge_vlan_aware', 'cidr', 'cidr6',
|
||||
'comments', 'gateway', 'gateway6', 'mtu', 'slaves', 'vlan-id', 'vlan-raw-device'];
|
||||
const [fdl, fList] = datalist(fieldsOf(ks.value));
|
||||
const ps = h('select', { class: 'field', 'data-k': 'policy' }, ...POLICIES.map((x) => h('option', { value: x, text: x })));
|
||||
ps.value = pol || 'ignore';
|
||||
const row = h('div', { class: 'erow pol' }, ks,
|
||||
h('input', { class: 'inp mono', list: fdl, value: field || '', placeholder: 'mtu', 'data-k': 'field' }),
|
||||
ps, iconBtn('trash', t('delete'), () => row.remove(), 'danger'), fList);
|
||||
ks.addEventListener('change', () => fList.replaceChildren(...fieldsOf(ks.value).map((v) => h('option', { value: v }))));
|
||||
return row;
|
||||
}
|
||||
// renderMapRows: one block per target, NIC names and own values
|
||||
function renderMapRows(p) {
|
||||
const box = $('#pMapRows');
|
||||
const prev = {};
|
||||
for (const b of $$('.pmap', box)) prev[b.dataset.target] = { nics: $('[data-k="nics"]', b).value, values: $('[data-k="values"]', b).value };
|
||||
box.replaceChildren();
|
||||
for (const tg of p.targets || []) {
|
||||
const m = (p.mapping || {})[tg] || {};
|
||||
const nics = prev[tg] ? prev[tg].nics : Object.entries(m.nics || {}).map(([a, b]) => a + '=' + b).join(', ');
|
||||
const vals = prev[tg] ? prev[tg].values : Object.entries(m.values || {}).flatMap(([key, fields]) => {
|
||||
const [item, id] = [key.slice(0, key.indexOf('/')), key.slice(key.indexOf('/') + 1)];
|
||||
return Object.entries(fields).map(([f, v]) => (item === 'dns' ? 'dns' : id) + ' ' + f + '=' + v);
|
||||
}).join('\n');
|
||||
box.appendChild(h('div', { class: 'pmap', dataset: { target: tg } },
|
||||
h('b', { text: tg }),
|
||||
h('label', { text: t('mapNics') }),
|
||||
h('input', { class: 'inp mono', 'data-k': 'nics', value: nics, placeholder: 'eno1=enp65s0f0, eno2=enp65s0f1', spellcheck: 'false' }),
|
||||
h('label', { text: t('mapValues') }),
|
||||
h('textarea', { class: 'inp', 'data-k': 'values', spellcheck: 'false', placeholder: 'vmbr1 cidr=10.5.0.12/24\ndns dns1=10.0.0.53' }, vals)));
|
||||
}
|
||||
}
|
||||
// readProfile reads the editor; bad lines of the mapping are reported in errs
|
||||
function readProfile(errs) {
|
||||
const p = {
|
||||
name: $('#pName').value.trim(), master: $('#pMaster').value, targets: chipsOn('#pTargets'), items: NITEMS.filter((k) => chipsOn('#pItems').includes(k)),
|
||||
prune: $('#pPrune').checked, policies: {}, protect: {}, mapping: {}, position: editingProfile ? editingProfile.position : S.profiles.length,
|
||||
};
|
||||
for (const row of $$('#pPolRows .erow')) {
|
||||
const k = $('select', row).value;
|
||||
const f = $('[data-k="field"]', row).value.trim();
|
||||
if (f) (p.policies[k] = p.policies[k] || {})[f] = $('[data-k="policy"]', row).value;
|
||||
}
|
||||
for (const b of $$('#pProtect .pprot')) {
|
||||
const l = $('textarea', b).value.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
if (l.length) p.protect[b.dataset.item] = l;
|
||||
}
|
||||
for (const b of $$('#pMapRows .pmap')) {
|
||||
const m = { nics: {}, values: {} };
|
||||
for (const pair of $('[data-k="nics"]', b).value.split(',').map((x) => x.trim()).filter(Boolean)) {
|
||||
const [a, c] = pair.split('=').map((x) => (x || '').trim());
|
||||
if (a && c) m.nics[a] = c; else if (errs) errs.push(b.dataset.target + ' : ' + pair);
|
||||
}
|
||||
for (const line of $('[data-k="values"]', b).value.split('\n').map((x) => x.trim()).filter(Boolean)) {
|
||||
const mm = line.match(/^(\S+)\s+([\w-]+)\s*=\s*(.*)$/);
|
||||
if (!mm) { if (errs) errs.push(b.dataset.target + ' : ' + line); continue; }
|
||||
const key = mm[1] === 'dns' ? 'dns/dns' : 'network/' + mm[1];
|
||||
(m.values[key] = m.values[key] || {})[mm[2]] = mm[3].trim();
|
||||
}
|
||||
if (Object.keys(m.nics).length || Object.keys(m.values).length) p.mapping[b.dataset.target] = m;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
async function saveProfile(ev) {
|
||||
ev.preventDefault();
|
||||
const errs = [];
|
||||
const p = readProfile(errs);
|
||||
if (errs.length) {
|
||||
$('#pErr').textContent = t('bulkBadLine', { l: errs.join(' ; ') });
|
||||
$('#pErr').hidden = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('PUT', 'api/profiles/' + encodeURIComponent(editingProfile ? editingProfile.name : '-'), p);
|
||||
$('#profileDlg').close();
|
||||
toast(t('saved'));
|
||||
S.profile = p.name;
|
||||
store.set('profile', p.name);
|
||||
MODES.node.state = null;
|
||||
await loadNodes();
|
||||
loadNodeState(true);
|
||||
} catch (e) {
|
||||
$('#pErr').textContent = e.message;
|
||||
$('#pErr').hidden = false;
|
||||
}
|
||||
}
|
||||
async function deleteProfile() {
|
||||
if (!editingProfile || !confirm(t('confirmDeleteProfile', { p: editingProfile.name }))) return;
|
||||
try {
|
||||
await api('DELETE', 'api/profiles/' + encodeURIComponent(editingProfile.name));
|
||||
$('#profileDlg').close();
|
||||
MODES.node.state = null;
|
||||
await loadNodes();
|
||||
loadNodeState(false);
|
||||
} catch (e) {
|
||||
$('#pErr').textContent = e.message;
|
||||
$('#pErr').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
function bind() {
|
||||
$('#refreshBtn').addEventListener('click', () => { if (S.view === 'diff') loadState(true); else if (S.view === 'runs') loadRuns(true); else loadAudit(true); });
|
||||
$('#refreshBtn').addEventListener('click', () => {
|
||||
if (S.view === 'diff') loadState(true);
|
||||
else if (S.view === 'runs') loadRuns(true);
|
||||
else if (S.view === 'clusters') loadClusters();
|
||||
else if (S.view === 'nodes') loadNodes();
|
||||
else loadAudit(true);
|
||||
});
|
||||
$$('#modeSw [data-mode]').forEach((b) => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||
$('#profileSel').addEventListener('change', (e) => {
|
||||
S.profile = e.target.value;
|
||||
store.set('profile', S.profile);
|
||||
MODES.node.state = null;
|
||||
S.open.clear();
|
||||
loadNodeState(false);
|
||||
});
|
||||
$('#addProfile').addEventListener('click', () => editProfile(null));
|
||||
$('#discoverBtn').addEventListener('click', discoverNodes);
|
||||
$('#testNodes').addEventListener('click', testNodes);
|
||||
$('#nodeForm').addEventListener('submit', saveNode);
|
||||
$('#nForget').addEventListener('click', forgetNode);
|
||||
$('#profileForm').addEventListener('submit', saveProfile);
|
||||
$('#pDelete').addEventListener('click', deleteProfile);
|
||||
$('#pMaster').addEventListener('change', () => { const p = readProfile(); renderTargetChips(p.targets); renderMapRows(readProfile()); });
|
||||
$('#pPolAdd').addEventListener('click', () => $('#pPolRows').appendChild(nodePolRow('network', '', 'sync')));
|
||||
$('#settingsBtn').addEventListener('click', () => openSettings());
|
||||
$('#setupBtn').addEventListener('click', () => {
|
||||
if (isNode()) {
|
||||
setView('nodes');
|
||||
if (can.admin()) editProfile(null);
|
||||
return;
|
||||
}
|
||||
setView('clusters');
|
||||
if (S.setup === 'clusters' && can.admin()) editCluster(null);
|
||||
});
|
||||
@@ -1703,6 +2256,7 @@
|
||||
S.deps = d.deps || {};
|
||||
} catch (e) { /* the replicated items stay read only */ }
|
||||
if (S.view === 'clusters') loadClusters();
|
||||
if (S.view === 'nodes') loadNodes();
|
||||
await loadState(false);
|
||||
if (S.view === 'runs') loadRuns(true);
|
||||
if (S.view === 'audit') loadAudit(true);
|
||||
|
||||
Reference in new issue
Block a user