feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m25s
Pull Request Tests / Run Tests (20.x) (push) Failing after 26s
Pull Request Tests / Run Tests (22.x) (push) Failing after 30s
Pull Request Tests / Test Summary (push) Failing after 5s

JOIN KEYS

v1.29.0 required an admin to pre-register every machine before its agent
would be spoken to. The security model was right; the workflow was not --
installing the agent should be enough to add a host.

POST /api/agent/join-keys mints one credential an operator hands out. A
host presenting it is enrolled automatically and immediately issued its
OWN per-agent token plus the public key it must pin, delivered in the
config frame. The join key is a bootstrap credential, never the host's
identity, so one key stays convenient without becoming a fleet-wide
skeleton key: every host remains individually revocable.

DIRECTORY

Collapsing the tree did nothing. applyTreeCollapse found the caret with
`.tree-caret i` and returned early when absent -- Font Awesome's SVG mode
rewrites <i> to <svg>, so that selector matched nothing and the early
return skipped setting hideBelowDepth. State now lives on the caret
button and is rotated by CSS.

The Discovery Plugins delete button called deleteDiscoveryPlugin(), which
was never defined. The pane also had no .actionMessage, and confirmations
render into one -- without it the promise never settles, so an awaited
confirmation hangs forever and the action silently never happens.

Plugin instances can now be edited.

DISCOVERY

A fresh install presented its own five containers as unmanaged
discoveries. The Docker plugin now recognises the stack's compose project
and attaches each container to the service it implements. Container slugs
came from the container id, which changes on recreate, so every deploy
minted a new resource and orphaned the old one.

DOCS

/docs/discovery 404'd (no slug entry) and `agents` pointed at plugins.md,
leaving docs/agents.md unreachable. Adds docs/discovery.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 10:33:34 -04:00
parent b28a18064a
commit 7e9a271090
11 changed files with 563 additions and 39 deletions
+72 -1
View File
@@ -108,4 +108,75 @@ class Agent extends Model {
}
}
module.exports = { Agent };
// A join key: the one credential an operator hands out so a host can enroll
// itself. Requiring an admin to pre-register every machine before the agent
// would talk to them made adding a host a two-system chore -- installing the
// agent should be enough.
//
// A join key is NOT the agent's long-term credential. On first connect the
// server auto-enrolls the host and issues it a unique per-agent token, which
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
// operator experience to "one key" while still giving every host its own
// revocable identity -- revoking a single agent means something, and a host
// that is compromised does not hand over the credential for the whole fleet.
class AgentJoinKey extends Model {
static hashKey(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateKey() {
// `tjk_` so an operator can tell a join key from an agent token at a
// glance -- they are handled very differently.
return 'tjk_' + crypto.randomBytes(32).toString('hex');
}
// Resolve a presented key to a usable join key, or null. Expiry and
// revocation are both enforced here so no caller can forget one.
static async authenticate(rawKey) {
if (!rawKey || typeof rawKey !== 'string') return null;
const keyHash = this.hashKey(rawKey);
const matches = await this.list({ where: { keyHash } });
const key = matches && matches[0];
if (!key) return null;
if (key.revoked) return null;
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
return key;
}
static async issue({ label, createdBy, expiresInDays }) {
const raw = this.generateKey();
const key = await this.create({
id: crypto.randomUUID(),
label: label || 'default',
keyHash: this.hashKey(raw),
keyPrefix: raw.slice(0, 12),
revoked: false,
created_by: createdBy || null,
created_on: Math.floor(Date.now() / 1000),
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
use_count: 0
});
return { key, raw };
}
static fields = {
id: { type: 'uuid', primaryKey: true },
label: { type: 'string', isRequired: true },
keyHash: { type: 'string', isRequired: true },
keyPrefix: { type: 'string' },
revoked: { type: 'boolean', default: false },
created_by: { type: 'string' },
created_on: { type: 'integer' },
expires_on: { type: 'integer' },
use_count: { type: 'integer', default: 0 },
last_used_on: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.keyHash;
return data;
}
}
module.exports = { Agent, AgentJoinKey };
+2 -2
View File
@@ -20,7 +20,7 @@ const { PluginInstance } = require('./plugin_instance');
const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token');
const { Agent } = require('./agent');
const { Agent, AgentJoinKey } = require('./agent');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -35,7 +35,7 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.29.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.29.0",
"version": "1.30.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.29.0",
"version": "1.30.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+48 -6
View File
@@ -7,7 +7,15 @@ module.exports = {
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
// Containers in this compose project are the stack's own. They are already
// represented in the catalog as services, so they are recorded as managed
// and linked to the service they implement instead of arriving as
// unmanaged strangers a fresh install has to triage.
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
// The catalog host these containers run on, so they land in the tree
// instead of as roots.
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
],
validate: async (config) => {
@@ -48,23 +56,57 @@ module.exports = {
const resources = [];
const edges = [];
const stackProject = (config.stackProject || '').trim();
const hostSlug = (config.hostSlug || '').trim();
for (const c of containers) {
const labels = c.Labels || {};
const composeProject = labels['com.docker.compose.project'] || '';
const composeService = labels['com.docker.compose.service'] || '';
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
// A container id changes every time the container is recreated,
// so an id-derived slug made `docker compose up` mint a brand-new
// resource on every deploy and orphan the previous one. Prefer
// identifiers that survive a recreate: the compose project+service
// it belongs to, else its name.
const stableKey = composeProject && composeService
? `${composeProject}-${composeService}`
: (name || c.Id.substring(0, 12));
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
const isOwnStack = !!(stackProject && composeProject === stackProject);
resources.push({
kind: 'container',
name: name,
name: composeService || name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports
ports: ports,
composeProject: composeProject || undefined,
composeService: composeService || undefined,
containerName: name,
sourceId: stableKey,
// Part of the deployment we are running inside: already
// accounted for, not something to promote.
managed: isOwnStack ? true : undefined
}
});
// Attach the container to the service it implements when the
// catalog already has one under that slug (the bootstrap seeds
// `sso-manager`, `proxy`, `jump-host`, … using the same names
// compose uses). The reconciler drops an edge whose parent does
// not resolve, so an unmatched name is simply not linked.
if (isOwnStack && composeService) {
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
} else if (hostSlug) {
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
}
}
resolve({ resources, edges });
+95 -10
View File
@@ -5,7 +5,7 @@ const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const { Agent } = require('../models/agent');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
@@ -172,6 +172,54 @@ router.delete('/nodes/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// --- Join keys ---
// One key an operator hands out; hosts that present it enroll themselves and
// are immediately issued their own per-agent token. Listing never returns the
// key itself -- only its prefix and usage.
router.get('/join-keys', async (req, res, next) => {
try {
const keys = await AgentJoinKey.list();
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
} catch (err) { next(err); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
const { key, raw } = await AgentJoinKey.issue({
label: (label && String(label).trim()) || 'default',
createdBy: req.user.uid,
expiresInDays: expiresInDays ? Number(expiresInDays) : null
});
logAgentAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Shown once; only the hash is stored.
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
} catch (err) { next(err); }
});
router.post('/join-keys/:id/revoke', async (req, res, next) => {
try {
const key = await AgentJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.update({ revoked: true });
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Agents already enrolled keep working -- they hold their own tokens now,
// which is the whole point of exchanging the join key rather than using it
// as the long-term credential.
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.delete('/join-keys/:id', async (req, res, next) => {
try {
const key = await AgentJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.delete();
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
// --- Commands ---
// Addressed by agent id, not by token: a token is a credential and has no
// business travelling in a URL, being logged, or sitting in browser history.
@@ -227,8 +275,37 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// the peer is an anonymous stranger, and the old code treated it as a
// trusted node purely for presenting a non-empty string.
let agent = null;
let issuedToken = null; // set when this connection auto-enrolled
try {
agent = await Agent.authenticate(token);
// Not a known agent token -- try it as a join key. This is what makes
// "install the agent with a key and the host appears" work without an
// admin pre-registering every machine. The join key is exchanged for a
// per-agent token below, so it never becomes the host's long-term
// credential.
if (!agent) {
const joinKey = await AgentJoinKey.authenticate(token);
if (joinKey) {
const hostname = (url.searchParams.get('hostname') || '').trim();
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
await joinKey.update({
use_count: (joinKey.use_count || 0) + 1,
last_used_on: Math.floor(Date.now() / 1000)
}).catch(() => {});
logAgentAudit('join', {
agentId: agent.id, agentName: agent.name, remoteAddr,
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
});
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
}
}
} catch (err) {
console.error('[Theta Agent] authentication lookup failed:', err.message);
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
@@ -294,16 +371,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
agentManager.unregisterAgent(agent.id, ws);
});
// Send initial welcome/config payload
// Send initial welcome/config payload. When this connection enrolled via a
// join key it also carries the credentials the agent should persist and use
// from now on: its own token, and the public key it must pin to verify
// signed commands. Handing the public key over here is what removes the
// last manual step -- an agent installed with only a join key ends up fully
// configured without anyone copying values between two machines.
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
message: 'Connected to SSO Manager C2',
protocol_version: '1.2.0',
agent_id: agent.id
}
}));
const payload = {
message: 'Connected to SSO Manager C2',
protocol_version: '1.2.0',
agent_id: agent.id
};
if (issuedToken) {
payload.enrolled = true;
payload.auth_token = issuedToken;
payload.public_key = await agentManager.publicKeyBase64();
}
ws.send(JSON.stringify({ type: 'config', payload }));
} catch (e) {}
});
};
+5 -1
View File
@@ -34,8 +34,12 @@ const DOCS = {
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
// guide the Directory links to -- was unreachable in the app.
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
// The Discovery tab's help icon links here; without an entry it 404'd.
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
+142 -8
View File
@@ -1,5 +1,16 @@
<%- include('top') %>
<style>
/* The caret's rotation is driven by a class on the BUTTON, not by swapping
icon classes on its child: Font Awesome's SVG-with-JS mode replaces the
<i> with an <svg>, so anything keyed to the child element stops working
the moment its observer runs. Targeting both covers either state. */
.tree-caret > i,
.tree-caret > svg { transition: transform .12s ease-in-out; }
.tree-caret.tree-caret-collapsed > i,
.tree-caret.tree-caret-collapsed > svg { transform: rotate(-90deg); }
</style>
<div class="container mt-4">
<div class="row">
<div class="col-12">
@@ -230,6 +241,13 @@
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<!-- app.messages confirmations render into a `.actionMessage` inside
the target and do NOTHING without one: the returned promise never
settles, so an awaited confirmation hangs forever and the action
it gates silently never happens. This pane had no such element,
which is why Delete appeared dead. Any pane that asks the
operator to confirm something needs this. -->
<div class="actionMessage" style="display:none"></div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
@@ -914,13 +932,23 @@
hideBelowDepth = null;
$row.show();
const $icon = $row.find('.tree-caret i');
if (!$icon.length) return;
// Visual state lives on the .tree-caret BUTTON, rotated by CSS, and the
// hide decision is made from `collapsed` alone.
//
// This used to read `.tree-caret i` and bail out when it found nothing.
// Font Awesome runs in SVG-with-JS mode here: its mutation observer
// rewrites every <i class="fa-..."> into an <svg>, so moments after a
// render that selector matches nothing, the function returned early
// WITHOUT setting hideBelowDepth, and collapsing silently did nothing at
// all. Never make the collapse logic depend on an element another library
// is free to replace.
const $caret = $row.find('.tree-caret');
if (!$caret.length) return; // leaf row: nothing to collapse
if (collapsed.has(id)) {
$icon.removeClass('fa-chevron-down').addClass('fa-chevron-right');
$caret.addClass('tree-caret-collapsed');
hideBelowDepth = depth;
} else {
$icon.removeClass('fa-chevron-right').addClass('fa-chevron-down');
$caret.removeClass('tree-caret-collapsed');
}
});
}
@@ -1963,6 +1991,7 @@
<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-secondary" title="Edit" onclick="openEditDiscoveryPluginModal('${p.id}')"><i class="fa-solid fa-pen"></i> Edit</button>
<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>
@@ -2066,18 +2095,27 @@
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
function dpConfigFormHtml(type, prefix) {
// `values` pre-fills the form for edit mode. Secret fields are never returned
// by the API in the clear (they live in OpenBao and come back masked), so
// they are rendered EMPTY with a "leave blank to keep" hint rather than
// prefilled with `********` -- submitting the mask back would otherwise store
// the literal asterisks as the secret.
function dpConfigFormHtml(type, prefix, values) {
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
var schema = t && t.configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
values = values || {};
var html = '';
schema.forEach(function(f) {
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var req = (f.required && !f.secret) ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = '';
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"';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
});
return html;
}
@@ -2137,6 +2175,102 @@
});
}
// Edit an existing instance. Non-secret config goes to PUT /plugins/:id;
// secrets go to PUT /plugins/:id/secrets and only when the operator actually
// typed a new value -- they are two endpoints because the DB row must never
// hold a secret (see routes/api_plugins.js).
function openEditDiscoveryPluginModal(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
const values = Object.assign({}, p.config || {}, { __isEdit: true });
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<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>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="edit-plugin-name" class="form-control shadow-sm" value="${esc(p.name)}">
<div class="form-text">Slug <code>${esc(p.slug)}</code> is stable and does not change.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Schedule</label>
${dpCronSelectHtml('ep-', p.cron)}
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="edit-plugin-enabled" ${p.enabled ? 'checked' : ''}>
<label class="form-check-label fw-semibold" for="edit-plugin-enabled">Loaded (runs on its schedule)</label>
</div>
<hr><h6 class="fw-bold">Configuration</h6>
<div id="edit-plugin-config-fields">${dpConfigFormHtml(p.pluginType, 'ep-', values)}</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
</div>
`;
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
});
}
async function saveEditedDiscoveryPlugin(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
const name = ($('#edit-plugin-name').val() || '').trim();
if (!name) { app.messages.toast('Name is required', 'warning'); return; }
const flat = dpCollectConfig(p.pluginType, 'ep-');
const type = discoveryPluginTypes.find(t => t.type === p.pluginType);
const schema = (type && type.configSchema) || [];
// Split by the schema so a secret never rides along in the DB payload, and
// an untouched secret field is not sent at all.
const config = {};
const secrets = {};
schema.forEach(f => {
const v = flat[f.key];
if (f.secret) { if (v) secrets[f.key] = v; }
else config[f.key] = v;
});
try {
await app.api.put(`plugins/${id}`, {
name,
cron: dpCronFromForm('ep-'),
enabled: $('#edit-plugin-enabled').is(':checked'),
config
});
if (Object.keys(secrets).length) await app.api.put(`plugins/${id}/secrets`, secrets);
app.modal.close();
app.messages.toast('Plugin updated', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error saving plugin: ' + (e.message || e), 'danger');
}
}
// Was referenced by the card's trash button but never defined, so clicking it
// only threw a ReferenceError -- delete appeared to do nothing.
async function deleteDiscoveryPlugin(id) {
const p = discoveryPlugins.find(x => x.id === id);
const label = p ? (p.name || p.slug) : 'this plugin';
const $card = $('#plugins-tab-pane');
const confirmed = await app.messages.confirm(
`Delete discovery plugin "${label}"? Its schedule stops and its stored secrets are removed. Resources it already discovered stay in the Directory.`,
$card, 'warning');
if (!confirmed) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + (e.message || e), 'danger');
}
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();