chore(agents): label the Install Agent modal fields as Theta Directory URL

This commit is contained in:
2026-08-09 20:33:29 -07:00
parent 72040b1851
commit f98615aae2
+54 -54
View File
@@ -1,4 +1,4 @@
<%- include('top') %> <%- include('top') %>
<style> <style>
/* The caret's rotation is driven by a class on the BUTTON, not by swapping /* The caret's rotation is driven by a class on the BUTTON, not by swapping
@@ -63,7 +63,7 @@
</select> </select>
<div class="input-group input-group-sm shadow-sm" style="width: 230px;"> <div class="input-group input-group-sm shadow-sm" style="width: 230px;">
<span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span> <span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span>
<input type="text" id="user-access-uid" class="form-control" placeholder="uid" list="access-uid-list" <input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
onkeydown="if(event.key==='Enter'){openUserAccessModal();}"> onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
<datalist id="access-uid-list"></datalist> <datalist id="access-uid-list"></datalist>
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button> <button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
@@ -314,14 +314,14 @@
<label class="form-label font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label> <label class="form-label font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label>
<select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()"> <select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
<option value="">-- Custom / Manual --</option> <option value="">-- Custom / Manual --</option>
<option value="docker">📦 Docker Engine / Container</option> <option value="docker">📦 Docker Engine / Container</option>
<option value="systemd">⚙️ Linux Systemd Service</option> <option value="systemd">⚙️ Linux Systemd Service</option>
<option value="postgresql">🗄️ PostgreSQL Database</option> <option value="postgresql">🗄️ PostgreSQL Database</option>
<option value="redis">🔴 Redis In-Memory Cache</option> <option value="redis">🔴 Redis In-Memory Cache</option>
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option> <option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option> <option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
<option value="unifi">📶 UniFi Network Controller</option> <option value="unifi">📶 UniFi Network Controller</option>
<option value="k8s">☸️ Kubernetes Cluster Node</option> <option value="k8s">☸️ Kubernetes Cluster Node</option>
</select> </select>
<label class="form-label">Sub Type</label> <label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql..."> <input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
@@ -602,7 +602,7 @@
</div> </div>
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none"> <div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao secret values will not be displayed again once saved. <i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
</div> </div>
</div> </div>
@@ -718,7 +718,7 @@
rawResources = []; rawResources = [];
for (const r of resResources.results) { for (const r of resResources.results) {
r.hostName = ''; r.hostName = '—';
r.parentId = null; r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id); const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) { if (parentEdge) {
@@ -770,13 +770,13 @@
} }
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; } if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
if (!a.isOnline) { if (!a.isOnline) {
const seen = (a.lastSeen || a.last_seen) ? ' last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : ''; const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return; n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
} }
const t = a.lastTelemetry || {}; const t = a.lastTelemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90); const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
n.agentColor = high ? '#ffc107' : '#198754'; n.agentColor = high ? '#ffc107' : '#198754';
n.agentStatusTitle = high ? 'Connected high load' : 'Connected healthy'; n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
} }
// Agent tab body for the resource modal. // Agent tab body for the resource modal.
@@ -1112,7 +1112,7 @@
async function openUserAccessModal() { async function openUserAccessModal() {
const uid = ($('#user-access-uid').val() || '').trim(); const uid = ($('#user-access-uid').val() || '').trim();
if (!uid) return; if (!uid) return;
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading</p>' }); app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
try { try {
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid)); const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
const data = res.results; const data = res.results;
@@ -1202,7 +1202,7 @@
} }
n.indentHtml = indentHtml; n.indentHtml = indentHtml;
n.depth = depth; n.depth = depth;
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '' : (n.name || ''); n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '…' : (n.name || '');
// A leaf gets a spacer of the same width, so names stay aligned down // A leaf gets a spacer of the same width, so names stay aligned down
// the column instead of jittering by whether a row has children. // the column instead of jittering by whether a row has children.
n.caretHtml = n.children.length n.caretHtml = n.children.length
@@ -1231,7 +1231,7 @@
applyTreeCollapse(); applyTreeCollapse();
} }
// ── Collapsible tree ─────────────────────────────────────────────────────── // ── Collapsible tree ───────────────────────────────────────────────────────
// Which nodes are collapsed, by resource id. Persisted so the shape of the // Which nodes are collapsed, by resource id. Persisted so the shape of the
// tree survives a refresh (and the Directory self-heal reload that follows // tree survives a refresh (and the Directory self-heal reload that follows
// most edits) -- a tree that re-expands every time is worse than no tree. // most edits) -- a tree that re-expands every time is worse than no tree.
@@ -1506,14 +1506,14 @@
<div class="p-3"> <div class="p-3">
<p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p> <p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
<div class="mb-3"> <div class="mb-3">
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()"> <input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()">
<input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}"> <input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}">
<div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;"> <div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
${targets.map((r, i) => ` ${targets.map((r, i) => `
<button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)"> <button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<strong>${esc(r.name)}</strong> <strong>${esc(r.name)}</strong>
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span> <span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
</div> </div>
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''} ${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
</button> </button>
@@ -1915,7 +1915,7 @@
$select.append('<option value="">-- Select Parent Resource Secret --</option>'); $select.append('<option value="">-- Select Parent Resource Secret --</option>');
currentParentSecretsList.forEach(p => { currentParentSecretsList.forEach(p => {
const valStr = `INHERIT:${p.parentSlug}:${p.key}`; const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
const labelStr = `${p.parentName || p.parentSlug} ${p.key}`; const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`); $select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
}); });
} }
@@ -2064,7 +2064,7 @@
await loadResources(); await loadResources();
loadDiscoveryResources(); loadDiscoveryResources();
app.modal.close(); app.modal.close();
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' created groups: ' + res.groups.join(', ') : ''), 'success'); app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
} catch (err) { } catch (err) {
promoteSlug = slug; promoteSlug = slug;
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger'); app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
@@ -2232,7 +2232,7 @@
async function deleteResource(id) { async function deleteResource(id) {
// Called from the outer table's row button, not from inside the resource // Called from the outer table's row button, not from inside the resource
// modal target the page's own card so the confirm/error renders // modal — target the page's own card so the confirm/error renders
// somewhere actually visible. // somewhere actually visible.
const $target = $('#resources-list'); const $target = $('#resources-list');
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger'); const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
@@ -2343,14 +2343,14 @@
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`; joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
} else if (selectedKeyId) { } else if (selectedKeyId) {
const k = agentJoinKeys.find(x => x.id === selectedKeyId); const k = agentJoinKeys.find(x => x.id === selectedKeyId);
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`; joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
} else { } else {
joinCmd = '# Create a join key above, or select one you already have the value for.'; joinCmd = '# Create a join key above, or select one you already have the value for.';
} }
$('#agent-join-command').text(joinCmd); $('#agent-join-command').text(joinCmd);
// Windows: the installer is a GitHub release artifact (built by the // Windows: the installer is a GitHub release artifact (built by the
// theta-agent release workflow nothing binary lives in this repo). It // theta-agent release workflow — nothing binary lives in this repo). It
// takes the same values as /SERVER_URL /JOIN_KEY command-line params. // takes the same values as /SERVER_URL /JOIN_KEY command-line params.
const WIN_SETUP = 'theta-agent-2.1.0-windows-amd64-setup.exe'; const WIN_SETUP = 'theta-agent-2.1.0-windows-amd64-setup.exe';
const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`; const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`;
@@ -2473,7 +2473,7 @@
</ul> </ul>
<div class="tab-content mb-3"> <div class="tab-content mb-3">
<!-- ── Join key: one credential, host enrolls itself ────────────── --> <!-- ── Join key: one credential, host enrolls itself ────────────── -->
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel"> <div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
<div class="card border-success"> <div class="card border-success">
<div class="card-header py-2 fw-bold small bg-success-subtle"> <div class="card-header py-2 fw-bold small bg-success-subtle">
@@ -2483,14 +2483,14 @@
<p class="small text-muted mb-3"> <p class="small text-muted mb-3">
Run this on any host and it enrolls itself. The SSO issues that host its own Run this on any host and it enrolls itself. The SSO issues that host its own
token and public key on first connect, and the agent writes both into its token and public key on first connect, and the agent writes both into its
<code>agent.yml</code> nothing to copy back and forth. One key works for as <code>agent.yml</code> — nothing to copy back and forth. One key works for as
many hosts as you like; each still gets its own revocable identity. many hosts as you like; each still gets its own revocable identity.
</p> </p>
<div class="d-flex gap-2 align-items-end mb-3"> <div class="d-flex gap-2 align-items-end mb-3">
<div class="flex-grow-1"> <div class="flex-grow-1">
<label class="form-label small fw-bold mb-1">Existing join keys</label> <label class="form-label small fw-bold mb-1">Existing join keys</label>
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select> <select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
<div class="form-text small">A key's value is shown only when it is created mint a new one if you don't have it saved.</div> <div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
</div> </div>
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()"> <button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
<i class="fa-solid fa-plus me-1"></i> New join key <i class="fa-solid fa-plus me-1"></i> New join key
@@ -2536,7 +2536,7 @@
</div> </div>
</div> </div>
<!-- ── Pre-register: bind to a host resource up front ───────────── --> <!-- ── Pre-register: bind to a host resource up front ───────────── -->
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel"> <div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
<div class="card border-primary mb-3" id="agent-enroll-card"> <div class="card border-primary mb-3" id="agent-enroll-card">
@@ -2583,11 +2583,11 @@
</ul> </ul>
<div class="tab-content" id="agent-install-tab-content"> <div class="tab-content" id="agent-install-tab-content">
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── --> <!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel"> <div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label small fw-bold mb-1">SSO Server URL</label> <label class="form-label small fw-bold mb-1">Theta Directory URL</label>
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()"> <input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -2616,11 +2616,11 @@
</div> </div>
</div> </div>
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── --> <!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel"> <div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-5"> <div class="col-md-5">
<label class="form-label small fw-bold mb-1">SSO Server URL</label> <label class="form-label small fw-bold mb-1">Theta Directory URL</label>
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()"> <input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
@@ -2717,12 +2717,12 @@
// Only hosts can carry an agent -- the API rejects anything else, so don't // Only hosts can carry an agent -- the API rejects anything else, so don't
// offer it here. // offer it here.
const $sel = $('#agent-enroll-resource').empty(); const $sel = $('#agent-enroll-resource').empty();
$sel.append('<option value="">(not bound bind later)</option>'); $sel.append('<option value="">(not bound — bind later)</option>');
rawResources rawResources
.filter(r => r.kind === 'host') .filter(r => r.kind === 'host')
.sort((a, b) => (a.name || '').localeCompare(b.name || '')) .sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.forEach(r => { .forEach(r => {
const taken = agentsByResource[r.id] ? ' already has an agent' : ''; const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
$sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id])); $sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
}); });
@@ -2749,12 +2749,12 @@
const $sel = $('#agent-join-key-select').empty(); const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) { if (!agentJoinKeys.length) {
$sel.append('<option value="">No join keys yet create one</option>'); $sel.append('<option value="">No join keys yet — create one</option>');
} else { } else {
$sel.append('<option value="">Select a key</option>'); $sel.append('<option value="">Select a key…</option>');
agentJoinKeys.forEach(k => { agentJoinKeys.forEach(k => {
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused'; const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}, ${used})`)); $sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
}); });
} }
@@ -2764,7 +2764,7 @@
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>'); $tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
} else { } else {
agentJoinKeysAll.forEach(k => { agentJoinKeysAll.forEach(k => {
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : ''; const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '—';
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts'; const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
const status = k.revoked const status = k.revoked
? '<span class="badge bg-secondary">Revoked</span>' ? '<span class="badge bg-secondary">Revoked</span>'
@@ -2773,7 +2773,7 @@
`<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`; `<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`;
const $row = $('<tr>').attr('data-join-key-row', k.id).append( const $row = $('<tr>').attr('data-join-key-row', k.id).append(
$('<td>').text(k.label), $('<td>').text(k.label),
$('<td>').append($('<code>').text(k.keyPrefix + '')), $('<td>').append($('<code>').text(k.keyPrefix + '…')),
$('<td>').text(created), $('<td>').text(created),
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })), $('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
$('<td>').html(status), $('<td>').html(status),
@@ -2792,7 +2792,7 @@
} }
async function viewAgentJoinKeyHosts(id, label) { async function viewAgentJoinKeyHosts(id, label) {
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading'); const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
try { try {
const res = await app.api.get(`agent/join-keys/${id}/agents`); const res = await app.api.get(`agent/join-keys/${id}/agents`);
const body = (res && (res.results || res)) || {}; const body = (res && (res.results || res)) || {};
@@ -2804,7 +2804,7 @@
const rows = agents.map(a => { const rows = agents.map(a => {
const dot = a.isOnline ? 'text-success' : 'text-muted'; const dot = a.isOnline ? 'text-success' : 'text-muted';
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never'; const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '')}</td><td>${esc(seen)}</td></tr>`; return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '—')}</td><td>${esc(seen)}</td></tr>`;
}).join(''); }).join('');
$out.html( $out.html(
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` + `<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
@@ -2857,7 +2857,7 @@
$('#agent-join-key-result').show().html( $('#agent-join-key-result').show().html(
'<div class="alert alert-success py-2 small mb-3">' '<div class="alert alert-success py-2 small mb-3">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> ' + '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
+ 'It is shown <strong>once</strong> only its hash is stored. It is already in the command below.' + 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
+ '</div>' + '</div>'
+ '<label class="form-label small fw-bold mb-1">Join key</label>' + '<label class="form-label small fw-bold mb-1">Join key</label>'
+ '<div class="input-group input-group-sm mb-3">' + '<div class="input-group input-group-sm mb-3">'
@@ -2881,7 +2881,7 @@
app.messages.toast('Give the agent a name first.', 'warning'); app.messages.toast('Give the agent a name first.', 'warning');
return; return;
} }
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling'); const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
try { try {
const res = await app.api.post('agent/enroll', { name, resourceId }); const res = await app.api.post('agent/enroll', { name, resourceId });
const body = res && (res.results || res); const body = res && (res.results || res);
@@ -2904,7 +2904,7 @@
keyWarn + keyWarn +
'<div class="alert alert-success py-2 small mb-2">' '<div class="alert alert-success py-2 small mb-2">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> ' + '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
+ 'This token is shown <strong>once</strong> only its hash is stored. ' + 'This token is shown <strong>once</strong> — only its hash is stored. '
+ 'If you lose it, rotate the agent to issue a new one.</div>' + 'If you lose it, rotate the agent to issue a new one.</div>'
+ '<label class="form-label small fw-bold mb-1">Agent token</label>' + '<label class="form-label small fw-bold mb-1">Agent token</label>'
+ '<div class="input-group input-group-sm mb-2">' + '<div class="input-group input-group-sm mb-2">'
@@ -2996,7 +2996,7 @@
: ''; : '';
const log = p.lastLog || '(no log captured for this run)'; const log = p.lastLog || '(no log captured for this run)';
app.modal.open({ app.modal.open({
title: 'Run log ' + (p.name || p.slug), title: 'Run log — ' + (p.name || p.slug),
size: 'lg', size: 'lg',
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>', bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
}); });
@@ -3025,16 +3025,16 @@
var discoveryPluginTypes = []; var discoveryPluginTypes = [];
// ── Discovery plugin config helpers (ported from plugins.ejs) ───────────── // ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
// Stored value is always a 5-field cron string; the dropdown picks a preset // Stored value is always a 5-field cron string; the dropdown picks a preset
// and "Custom" reveals the raw input. Config fields are driven by each // and "Custom…" reveals the raw input. Config fields are driven by each
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url / // plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
// tokenId / tokenSecret) are collected at create time. // tokenId / tokenSecret) are collected at create time.
var DP_CRON_PRESETS = [ var DP_CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' }, { key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' }, { key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' }, { key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom', cron: null }, { key: 'custom', label: 'Custom…', cron: null },
]; ];
function dpCronKeyFor(cron) { function dpCronKeyFor(cron) {
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0]; var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
@@ -3107,7 +3107,7 @@
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : ''; var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = ''; var val = '';
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"'; if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged type a new value to replace"'; if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' + html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>'; '<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
} }
@@ -3194,7 +3194,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label> <label class="form-label fw-bold">Plugin Type</label>
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled> <input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
<div class="form-text">The type is fixed once an instance exists create a new instance to use a different one.</div> <div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Instance Name</label> <label class="form-label fw-bold">Instance Name</label>
@@ -3216,7 +3216,7 @@
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button> <button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
</div> </div>
`; `;
app.modal.open({ title: 'Edit Discovery Plugin ' + p.name, bodyHtml: bodyHtml, size: 'lg' }); app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
}); });
} }
@@ -3301,7 +3301,7 @@
} }
} }
// ── Multi-Site & Master Node Controls ───────────────────────────────────── // ── Multi-Site & Master Node Controls ─────────────────────────────────────
async function refreshSiteStatus() { async function refreshSiteStatus() {
try { try {
const res = await app.api.get('directory-admin/site-status'); const res = await app.api.get('directory-admin/site-status');
@@ -3329,7 +3329,7 @@
'<div class="card mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' + '<div class="card mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' +
'<div class="card-body">' + '<div class="card-body">' +
'<h5 class="card-title d-flex align-items-center justify-content-between">' + '<h5 class="card-title d-flex align-items-center justify-content-between">' +
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : ' <strong>Spoke Site Node</strong>') + '</span>' + '<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : 'âš¡ <strong>Spoke Site Node</strong>') + '</span>' +
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' + '<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
'</h5>' + '</h5>' +
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' + '<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' +
@@ -3388,7 +3388,7 @@
loadDiscoveryPlugins(); loadDiscoveryPlugins();
refreshSiteStatus(); refreshSiteStatus();
// Keep the host status dots live: refresh the agent join periodically and on // Keep the host status dots live: refresh the agent join periodically and on
// socket.io agent.* broadcasts (dedicated socket the app default is P2PSub). // socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
refreshAgents(); refreshAgents();
setInterval(refreshAgents, 30000); setInterval(refreshAgents, 30000);
try { try {