fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168)
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
This commit is contained in:
@@ -1651,15 +1651,28 @@
|
||||
discoveryPlugins.forEach(p => {
|
||||
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||
// Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
|
||||
// lastError/lastLog) but was dropped here; show it so a plugin that errors
|
||||
// is visible without digging into logs.
|
||||
const runOk = p.lastStatus === 'ok';
|
||||
const runErr = p.lastStatus === 'error';
|
||||
const runState = p.lastRunAt
|
||||
? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
|
||||
: '<span class="text-muted">Never run</span>';
|
||||
const logsBtn = (p.lastLog || p.lastError)
|
||||
? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
|
||||
: '';
|
||||
const card = `
|
||||
<div class="card mb-3 border shadow-sm">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||
<div class="small">Last run: ${runState}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||
${logsBtn}
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||
@@ -1671,6 +1684,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
// "Never run" when a discovery plugin has no run yet; otherwise relative time.
|
||||
function fmtRunTs(ts) {
|
||||
if (!ts) return 'Never run';
|
||||
const m = moment(ts);
|
||||
return m.isValid() ? m.fromNow() : 'Never run';
|
||||
}
|
||||
|
||||
// Modal showing the discovery plugin's last run log + error (from the plugins
|
||||
// API's lastLog/lastError fields). Logs can be long, so render in a scrollable
|
||||
// <pre> rather than a toast.
|
||||
function showPluginLog(id) {
|
||||
const p = discoveryPlugins.find(x => x.id === id);
|
||||
if (!p) return;
|
||||
const body = p.lastError
|
||||
? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
|
||||
: '';
|
||||
const log = p.lastLog || '(no log captured for this run)';
|
||||
app.modal.open({
|
||||
title: 'Run log — ' + (p.name || p.slug),
|
||||
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>',
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleDiscoveryPlugin(id, state) {
|
||||
const endpoint = state ? 'load' : 'unload';
|
||||
try {
|
||||
|
||||
+51
-1
@@ -17,7 +17,10 @@
|
||||
<div class="tab-pane fade show active" id="tab-secrets">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<a href="/docs/vault" class="text-reset" title="Vault help & documentation"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="row">
|
||||
@@ -88,6 +91,20 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/<name>/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
|
||||
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -405,12 +422,44 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
document.getElementById('app-token').textContent = result.token;
|
||||
document.getElementById('app-name-display').textContent = name;
|
||||
document.getElementById('app-result-card').classList.remove('d-none');
|
||||
loadApps();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// List the minted external-app tokens (metadata only). Makes the Apps tab show
|
||||
// what's been minted instead of a credential that vanishes after the once-only
|
||||
// token display.
|
||||
async function loadApps() {
|
||||
const $list = document.getElementById('apps-list');
|
||||
if (!$list) return;
|
||||
$list.textContent = 'Loading…';
|
||||
try {
|
||||
const res = await fetch('/api/vault/apps', {
|
||||
headers: { 'auth-token': app.auth.getToken() }
|
||||
});
|
||||
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
|
||||
const { apps = [] } = await res.json();
|
||||
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
|
||||
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
|
||||
const ok = !a.lastError;
|
||||
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
|
||||
return `<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
|
||||
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
|
||||
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
|
||||
</div>
|
||||
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
} catch (err) {
|
||||
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||
}
|
||||
@@ -572,6 +621,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
||||
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||
loadApps();
|
||||
}
|
||||
loadSecrets();
|
||||
loadShared();
|
||||
|
||||
Reference in New Issue
Block a user