Files
sso-manager-node/nodejs/views/directory.ejs
T
wmantly 51b42b3d8a
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m43s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 27s
Pull Request Tests / Test Summary (push) Failing after 3s
fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0)
- 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
2026-08-04 22:56:19 -04:00

1898 lines
86 KiB
Plaintext

<%- include('top') %>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="directoryTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
<i class="fa-solid fa-server"></i> Directory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
<i class="fa-solid fa-network-wired"></i> Discovered Inventory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
<i class="fa-solid fa-plug"></i> Discovery Plugins
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="directoryTabsContent">
<div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
<div class="border-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-server"></i> Directory Management
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="name">Name (A-Z)</option>
<option value="kind">Kind</option>
<option value="env">Environment</option>
</select>
<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>
<input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
<datalist id="access-uid-list"></datalist>
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
</div>
<button class="btn btn-sm btn-outline-primary ms-1 shadow-sm" onclick="openAgentInstallModal()">
<i class="fa-solid fa-shield-halved me-1"></i> Install Agent
</button>
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource
</button>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="table-responsive">
<table class="card-body table table-striped mb-0">
<thead>
<tr>
<th class="ps-3">Resource</th>
<th>IP / Address</th>
<th>Access</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="resources-list" jq-repeat="resources">
<tr id="resource-row-{{id}}">
<td class="ps-3">
{{{indentHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
<a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
</a>
</td>
<td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
</td>
<td>{{{accessHtml}}}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
<i class="fa-solid fa-plus"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Discovery Tab Pane -->
<div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
<div class="border-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
<a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="table-responsive">
<table class="card-body table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Name / Source</th>
<th>Type</th>
<th>IP Address</th>
<th>Status</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody id="discovery-list" jq-repeat="discoveryResources">
<tr id="discovery-row-{{slug}}">
<td class="ps-3">
<div class="fw-bold">{{name}}</div>
<div class="text-muted small">
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
</div>
</td>
<td>
<span class="badge bg-secondary me-1">{{kind}}</span>
{{#metadata.discovery_sources}}
<span class="badge bg-info text-dark me-1" style="font-size: 0.7em;">{{.}}</span>
{{/metadata.discovery_sources}}
{{#metadata.subType}}
<span class="badge bg-light text-dark border">{{metadata.subType}}</span>
{{/metadata.subType}}
</td>
<td>
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
{{#metadata.interfaces.length}}
<div class="mt-1 small text-muted">
{{#metadata.interfaces}}
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
{{/metadata.interfaces}}
</div>
{{/metadata.interfaces.length}}
</td>
<td>
{{#metadata.managed}}
<span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
{{/metadata.managed}}
{{^metadata.managed}}
<span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
{{/metadata.managed}}
</td>
<td class="text-end pe-3">
{{^metadata.managed}}
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
</button>
{{/metadata.managed}}
{{#metadata.managed}}
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
Promoted
</button>
{{/metadata.managed}}
</td>
</tr>
</tbody>
<tbody id="discovery-empty-state" style="display: none;">
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
<h5>No resources found</h5>
<p>Check your filters or ensure the discovery agents are running.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Discovery Plugins Tab Pane -->
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
<div class="p-4 bg-white border-top">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
// --- Resource modal tab content, built once. Populated via .val() in
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
// DOM from these strings -- order matters, unlike the old always-in-DOM
// static modal. ---
var generalTabHtml = `
<div class="actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="res-id">
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Name</label>
<input type="text" id="res-name" class="form-control shadow-sm">
</div>
<div class="col-6">
<label class="form-label">Slug</label>
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace" readonly>
<div class="form-text">Derived from the name; read-only.</div>
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Kind</label>
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
<option value="site">Site</option>
<option value="host">Host</option>
<option value="service">Service (App)</option>
<option value="oauth">OAuth Integration</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">IP Address</label>
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
</div>
<div class="col-6">
<label class="form-label">Host / URI Address</label>
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://... or comma-separated IPs">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-production">
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
</div>
<hr>
<h6 class="text-muted">Catalog appearance</h6>
<p class="text-muted small">How this resource is presented on the user-facing catalog at <code>/</code>.</p>
<div class="row mb-3">
<div class="col-4">
<label class="form-label">Icon</label>
<div class="input-group shadow-sm">
<span class="input-group-text"><i id="res-icon-preview" class="fa-solid fa-cube"></i></span>
<input type="text" id="res-icon" class="form-control font-monospace" placeholder="fa-solid fa-film">
</div>
<small class="field-help text-muted d-block">
A Font Awesome class. Blank falls back to a per-kind default.
</small>
</div>
<div class="col-8">
<label class="form-label">Tagline</label>
<input type="text" id="res-tagline" class="form-control shadow-sm" maxlength="120"
placeholder="One line shown on the catalog card">
<small class="field-help text-muted d-block">
Card-length blurb. Blank falls back to the Description above.
</small>
</div>
</div>
`;
var detailsTabHtml = `
<div class="row mb-3" id="site-details-container" style="display: none;">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="res-is-current-site">
<label class="form-check-label" for="res-is-current-site">
Mark as Current Site
</label>
</div>
</div>
</div>
<div class="row mb-3" id="host-parent-container" style="display: none;">
<div class="col-12">
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
<select id="res-host-id" class="form-select shadow-sm border-primary">
<option value="">-- Select Parent --</option>
</select>
</div>
</div>
<div class="row mb-3" id="host-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">VMID</label>
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
</div>
<div class="col-4">
<label class="form-label">MAC Address</label>
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
</div>
<div class="col-4">
<label class="form-label">OS / Kernel</label>
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
</div>
</div>
<div class="row mb-3" id="service-ports-container" style="display: none;">
<div class="col-6">
<label class="form-label">Internal Port</label>
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
</div>
<div class="col-6">
<label class="form-label">External Port</label>
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
<small class="text-muted">Same as Internal if empty</small>
</div>
</div>
<div class="row mb-3" id="service-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">Git Repo</label>
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
</div>
<div class="col-4">
<label class="form-label">Install Path</label>
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
</div>
<div class="col-4">
<label class="form-label">Systemd Service</label>
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
</div>
</div>
<div id="oauth-details-container" style="display: none;">
<hr>
<h5>OAuth Configuration</h5>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
</div>
<div class="col-6">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
</div>
</div>
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
<button class="btn btn-outline-warning" onclick="rotateSecret()">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
</button>
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
</div>
</div>
<div class="row mb-3">
<div class="col-6" id="external-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-external">
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
</div>
</div>
<div class="col-6" id="public-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-public">
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
</div>
</div>
</div>
`;
var groupsTabHtml = `
<div class="mb-3" id="groups-tab-container">
<div class="actionMessage" style="display:none"></div>
<ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
<div class="input-group shadow-sm mt-2">
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
<datalist id="ldap-groups-datalist"></datalist>
<select class="form-select" id="new-group-level" style="max-width: 140px;">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
`;
var childrenTabHtml = `
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
<i class="fa-solid fa-plus"></i> Add Child Resource
</button>
</div>
<hr>
<h6 class="text-muted">Other Relationships (advanced)</h6>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="edges-list"></ul>
<div class="input-group shadow-sm mt-2">
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
<option value="parent">Has child</option>
<option value="child">Is child of</option>
</select>
<select class="form-select" id="new-edge-target">
<option value="">-- Select Resource --</option>
</select>
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
`;
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
// the Groups/Children tabs in add-mode (no resource id to scope them to).
function openResourceModal(title, id) {
app.modal.open({
title: title,
size: 'lg',
tabs: [
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
{id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
],
footer: {
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
},
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
});
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
}
function refreshChildrenUI(resourceId) {
const kids = allEdges.filter(e => e.parentId === resourceId)
.map(e => resourcesById[e.childId]).filter(Boolean);
const $list = $('#children-list').empty();
if (!kids.length) {
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
return;
}
for (const kid of kids) {
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
$('<a href="#" class="text-reset text-decoration-none"></a>')
.text(kid.name + ' (' + kid.slug + ')')
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
.appendTo($li);
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
$list.append($li);
}
}
var resourcesById = {};
var allGroups = [];
var allEdges = [];
var rawResources = [];
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
var accessSummary = {};
$(document).ready(async function() {
await loadResources();
const slug = app.modal.deepLinkSlug('/directory');
if (slug) {
const match = Object.values(resourcesById).find(r => r.slug === slug);
if (match) openEditModal(match.id);
}
});
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
var agentsByHost = {};
var agentsByToken = {};
// True when the agent/nodes endpoint itself was unreachable (network, or an
// older app without the agent route). When set we cannot tell "this host has
// no agent" apart from "the agent service is down", so we must NOT paint every
// host red as if it lacked an agent.
var agentsUnavailable = false;
async function loadResources() {
try {
const [resResources, resGroups, resEdges, resAccess, resAgents] = await Promise.all([
app.api.get('directory-admin/resources'),
app.api.get('directory-admin/groups'),
app.api.get('directory-admin/edges'),
// Access counts are a nicety, not load-bearing: if the LDAP join fails
// the table still renders, just without the Access column populated.
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
// Agents are a nicety too: never block the directory on them. Track
// whether the endpoint itself is reachable so host rows can tell "no
// agent on this host" from "agent service is down" (see attachAgentStatus).
app.api.get('agent/nodes')
.then(function(res){ agentsUnavailable = false; return res; })
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
]);
accessSummary = (resAccess && resAccess.results) || {};
resourcesById = {};
for (const r of resResources.results) {
r.metadata = r.metadata || {};
resourcesById[r.id] = r;
}
indexAgents((resAgents && resAgents.agents) || []);
allGroups = resGroups.results;
allEdges = resEdges.results;
rawResources = [];
for (const r of resResources.results) {
// Compute hostName from edges
r.hostName = '—';
r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) {
r.parentId = parentEdge.parentId;
const parent = resourcesById[parentEdge.parentId];
if (parent) r.hostName = parent.name;
}
rawResources.push(r);
}
renderTable();
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
// input accepts a free-typed uid whether or not the list ever arrives.
loadDirectoryUsers().then(function(users) {
$('#access-uid-list').html(users.map(function(u) {
return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
}).join(''));
}).catch(function(){ /* datalist is a convenience only */ });
} catch (err) {
console.error(err);
app.messages.toast('Failed to load data', 'danger');
}
}
// Build the hostname->agent and token->agent lookup maps from /api/agent/nodes.
function indexAgents(agents) {
agentsByHost = {};
agentsByToken = {};
for (const a of agents || []) {
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
if (hn) agentsByHost[hn] = a;
if (a.token) agentsByToken[a.token] = a;
}
}
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
// stores the agent on resourcesById so the Metrics tab can find it.
function attachAgentStatus(n) {
n.isHost = true;
const name = (n.name || '').toLowerCase();
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
n.agent = a || null;
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
if (!a) {
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
// false red alarm across every host.
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return;
}
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
const t = a.telemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
n.agentColor = high ? '#ffc107' : '#198754';
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
}
// Metrics tab body for the resource modal (snapshot of the joined agent).
function metricsTabHtml(agent) {
if (!agent) {
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
}
const d = agent.discovery || {};
const t = agent.telemetry || {};
const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
return `<div class="p-3">
<div class="mb-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
</div>
<div class="row g-3">
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
<div class="col-6">GPU <strong>${gpu}</strong></div>
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
</div>
<hr><h6>Discovery</h6>
<div class="row small text-muted">
<div class="col-6">OS: ${esc(d.os || '')}</div>
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
<div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
<div class="col-6">Location: ${esc(d.location || '')}</div>
</div>
</div>`;
}
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
async function refreshAgents() {
try {
const res = await app.api.get('agent/nodes');
indexAgents((res && res.agents) || []);
agentsUnavailable = false;
renderTable();
} catch (e) {
agentsUnavailable = true;
renderTable(); // re-render so dots flip to neutral, not stale green
}
}
// "Who can reach this?" at a glance. A resource with no linked group is not a
// locked-down resource -- it is an unreachable one, and a group whose LDAP
// entry has been deleted grants nothing, so both get called out rather than
// rendering as a quiet zero.
function accessCellHtml(resourceId) {
const summary = accessSummary[resourceId];
if (!summary || !summary.groups.length) {
return '<span class="badge bg-light text-dark border" title="No LDAP group gates this resource">no groups</span>';
}
const dangling = summary.groups.filter(g => !g.exists);
let html = '<span class="badge bg-primary" title="' +
summary.groups.map(g => g.cn + ' (' + g.accessLevel + ', ' + g.memberCount + ')').join('&#10;') +
'"><i class="fa-solid fa-user-group"></i> ' + summary.memberCount + '</span>';
html += ' <small class="text-muted">' + summary.groups.length +
' group' + (summary.groups.length === 1 ? '' : 's') + '</small>';
if (dangling.length) {
html += ' <span class="badge bg-warning text-dark" title="Linked group missing in LDAP: ' +
dangling.map(g => g.cn).join(', ') + '">!</span>';
}
return html;
}
// Reverse lookup: what can a given user reach? The question an access-control
// pane must answer and the directory previously could not -- /api/discovery/me
// only ever answered it for the caller themselves.
async function openUserAccessModal() {
const uid = ($('#user-access-uid').val() || '').trim();
if (!uid) return;
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
try {
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
const data = res.results;
let html;
if (!data.resources.length) {
html = '<p class="text-muted">This user is in ' + data.groups.length +
' group(s), none of which gate a directory resource.</p>';
} else {
html = '<table class="table table-sm"><thead><tr><th>Resource</th><th>Kind</th>' +
'<th>Via group</th><th>Level</th></tr></thead><tbody>' +
data.resources.map(function(r) {
return '<tr><td><a href="/directory/' + encodeURIComponent(r.slug) + '">' + r.name + '</a></td>' +
'<td><span class="badge bg-secondary">' + r.kind + '</span></td>' +
'<td><code>' + r.groupCn + '</code></td>' +
'<td>' + r.accessLevel + '</td></tr>';
}).join('') + '</tbody></table>';
}
app.modal.open({ title: 'Access for ' + data.uid, bodyHtml: html, size: 'lg' });
} catch (err) {
app.modal.open({
title: 'Access for ' + uid,
bodyHtml: '<p class="text-danger">' + ((err && err.message) || 'Lookup failed') + '</p>'
});
}
}
function renderTable() {
const filter = $('#search-filter').val().toLowerCase();
const sort = $('#sort-by').val();
let filtered = rawResources.filter(r => {
if (!filter) return true;
return (r.name || '').toLowerCase().includes(filter) ||
(r.slug || '').toLowerCase().includes(filter) ||
(r.kind || '').toLowerCase().includes(filter) ||
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
(r.hostName || '').toLowerCase().includes(filter);
});
// Sort
filtered.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
if (sort === 'env') {
const ae = a.metadata?.isProduction ? 0 : 1;
const be = b.metadata?.isProduction ? 0 : 1;
return ae - be || a.name.localeCompare(b.name);
}
return 0;
});
let finalRenderList = [];
const map = {};
const roots = [];
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => {
const node = map[r.id];
if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node);
} else {
roots.push(node);
}
});
const flatten = (nodes, depth) => {
nodes.forEach(n => {
let indentHtml = '';
for(let i = 0; i < depth; i++) {
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
}
if (depth > 0) {
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
}
n.indentHtml = indentHtml;
n.accessHtml = accessCellHtml(n.id);
if (n.kind === 'host') attachAgentStatus(n);
finalRenderList.push(n);
if (n.children.length > 0) {
flatten(n.children, depth + 1);
}
});
};
flatten(roots, 0);
$.scope.resources.empty();
for (const r of finalRenderList) {
$.scope.resources.push(r);
}
}
function toggleFormFields() {
const kind = $('#res-kind').val();
if (kind === 'host') {
$('#host-parent-container').show();
$('#host-details-container').show();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').hide();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').hide();
} else if (kind === 'service') {
$('#host-parent-container').show();
$('#host-details-container').hide();
$('#service-ports-container').show();
$('#service-details-container').show();
$('#oauth-details-container').hide();
$('#external-container').show();
$('#public-container').show();
$('#site-details-container').hide();
} else if (kind === 'oauth') {
$('#host-parent-container').show();
$('#host-details-container').hide();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').show();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').hide();
} else { // site
$('#host-parent-container').hide();
$('#host-details-container').hide();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').hide();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').show();
}
populateHostDropdown($('#res-host-id').val());
}
// Delegated (not direct) since app.modal.open() rebuilds the modal body
// from scratch every call -- a direct .on() bind here would silently stop
// firing after the first Add/Edit.
app.modal.on('input change', '#res-name, #res-kind', function() {
const id = $('#res-id').val();
if (!id && $('#res-name').val()) {
const name = $('#res-name').val();
const kind = $('#res-kind').val();
let prefix = '';
if (kind === 'service') prefix = 'app_';
if (kind === 'host') prefix = 'host_';
if (kind === 'site') prefix = 'site_';
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
$('#res-slug').val(slug);
}
});
// Per-kind fallbacks, kept in sync with KIND_ICONS in views/landing.ejs so the
// preview shows what the catalog will actually render for a blank icon.
var KIND_ICON_FALLBACK = {
site: 'fa-solid fa-city',
host: 'fa-solid fa-server',
service: 'fa-solid fa-cube',
oauth: 'fa-solid fa-key'
};
function updateIconPreview() {
const raw = ($('#res-icon').val() || '').trim();
const cls = raw || KIND_ICON_FALLBACK[$('#res-kind').val()] || 'fa-solid fa-cube';
// Replace the class wholesale rather than toggling: a typed class is
// arbitrary user input and there is no reliable way to remove "whatever was
// there before" incrementally.
$('#res-icon-preview').attr('class', cls);
}
app.modal.on('input change', '#res-icon, #res-kind', updateIconPreview);
function openAddModal(parentId, parentKind) {
openResourceModal('Add Resource', null);
$('#res-id').val('');
$('#res-name').val('');
$('#res-slug').val('');
let defaultKind = 'service';
if (parentKind === 'site') defaultKind = 'host';
if (parentKind === 'host') defaultKind = 'service';
$('#res-kind').val(defaultKind);
if (!parentId && defaultKind === 'service') {
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (currentSite) parentId = currentSite.id;
}
if (!parentId && defaultKind === 'host') {
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (currentSite) parentId = currentSite.id;
}
$('#res-description').val('');
$('#res-ip').val('');
$('#res-address').val('');
$('#res-subtype').val('');
$('#res-vmid').val('');
$('#res-mac').val('');
$('#res-os').val('');
$('#res-port').val('');
$('#res-external-port').val('');
$('#res-git-repo').val('');
$('#res-install-path').val('');
$('#res-systemd').val('');
$('#res-icon').val('');
$('#res-tagline').val('');
updateIconPreview();
$('#res-is-production').prop('checked', false);
$('#res-is-external').prop('checked', false);
$('#res-is-public').prop('checked', false);
$('#res-is-current-site').prop('checked', false);
populateHostDropdown(parentId || '');
toggleFormFields();
}
var ldapGroupsCache = null;
async function loadLdapGroups() {
try {
if (!ldapGroupsCache) {
const res = await app.api.get('group');
ldapGroupsCache = res.results;
}
// Re-populate every call, not just the first -- #ldap-groups-datalist
// is rebuilt fresh (empty) on every app.modal.open(), so returning
// early here on a cache hit left the second and later modal opens
// with no autocomplete options at all.
const $datalist = $('#ldap-groups-datalist');
$datalist.empty();
for (const cn of ldapGroupsCache) {
$datalist.append($('<option>').val(cn));
}
} catch (err) {
console.error('Failed to load LDAP groups', err);
}
}
function populateHostDropdown(selectedId) {
const kind = $('#res-kind').val();
const $target = $('#res-host-id');
$target.empty().append('<option value="">-- Select Parent --</option>');
Object.values(resourcesById).forEach(r => {
if (r.id === $('#res-id').val()) return; // cannot be parent of itself
if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'oauth' && r.kind === 'service') {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
}
});
if (selectedId) $target.val(selectedId);
}
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
// the modal body on every app.modal.open() call, and jq-repeat's
// MutationObserver-based (re)registration for a same-id element that's
// destroyed and recreated runs asynchronously -- populating it
// synchronously right after open() (as this function is called) would race
// that and silently lose the pushed data on the second and later opens.
// uid from a member/owner DN, e.g. "cn=jdoe,ou=people,..." -> "jdoe".
function uidFromDn(dn) {
const m = dn.match(/cn=[a-zA-Z0-9_\-@.]+/);
return m ? m[0].replace('cn=', '') : dn;
}
var directoryUserCache = null;
async function loadDirectoryUsers() {
if (!directoryUserCache) {
const data = await app.user.list();
directoryUserCache = data.results;
}
return directoryUserCache;
}
// Renders one associated group's member/owner management block: a
// collapsible member list (remove button per member) plus an "add member"
// dropdown of users not already in the group. Fetches the live LDAP group
// detail (member/owner DN arrays) rather than relying on `allGroups`, which
// only holds the resource<->group association record, not membership.
async function renderGroupMembership(g, resourceId) {
const $li = $('<li class="list-group-item"></li>');
const $head = $('<div class="d-flex justify-content-between align-items-center"></div>');
const $span = $('<span></span>');
$span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
$('<strong></strong>').text(g.groupCn).appendTo($span);
$('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
$head.append($span);
const collapseId = 'group-members-' + g.id;
const $actions = $('<span></span>');
$('<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-bs-toggle="collapse"><i class="fa-solid fa-user-group"></i> Members</button>')
.attr('data-bs-target', '#' + collapseId)
.appendTo($actions);
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
.on('click', function(){ removeGroup(g.id); })
.appendTo($actions);
$head.append($actions);
$li.append($head);
const $collapse = $('<div class="collapse mt-2"></div>').attr('id', collapseId);
$li.append($collapse);
let detail, userlist;
try {
[detail, userlist] = await Promise.all([
app.group.get(g.groupCn).then(d => d.results),
loadDirectoryUsers(),
]);
} catch (err) {
console.error(err);
$collapse.append('<p class="text-danger small mb-0">Failed to load group membership.</p>');
return $li;
}
const memberDns = Array.isArray(detail.member) ? detail.member : (detail.member ? [detail.member] : []);
const $memberList = $('<ul class="list-group list-group-flush mb-2"></ul>');
if (!memberDns.length) {
$memberList.append('<li class="list-group-item text-muted px-0">No members.</li>');
}
for (const dn of memberDns) {
const uid = uidFromDn(dn);
const $mLi = $('<li class="list-group-item d-flex justify-content-between align-items-center px-0"></li>');
$('<span></span>').append('<i class="fa-solid fa-user me-1"></i>').append(document.createTextNode(uid)).appendTo($mLi);
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-user-slash"></i></button>')
.on('click', function(){ removeResourceGroupMember(g.groupCn, uid, resourceId); })
.appendTo($mLi);
$memberList.append($mLi);
}
$collapse.append($memberList);
const toAdd = userlist.filter(function(u){ return !memberDns.includes(u.dn); });
const $dropdown = $('<div class="dropdown"></div>');
$('<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"><i class="fa-solid fa-user-plus"></i> Add member</button>')
.appendTo($dropdown);
const $menu = $('<div class="dropdown-menu shadow-lg" style="max-height: 240px; overflow-y: auto;"></div>');
for (const u of toAdd) {
$('<a class="dropdown-item" href="#"></a>').text(u.uid)
.on('click', function(e){ e.preventDefault(); addResourceGroupMember(g.groupCn, u.uid, resourceId); })
.appendTo($menu);
}
$dropdown.append($menu);
$collapse.append($dropdown);
return $li;
}
async function refreshGroupsUI(resourceId) {
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
const $list = $('#groups-list').empty();
if (!myGroups.length) {
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
return;
}
for (const g of myGroups) {
$list.append(await renderGroupMembership(g, resourceId));
}
}
async function addResourceGroupMember(groupCn, uid, resourceId) {
try {
const data = await app.api.put('group/' + groupCn + '/' + uid, {});
await refreshGroupsUI(resourceId);
app.messages.action(data.message, $('#groups-tab-container'), 'success');
app.util.revealItem($('#groups-list'));
} catch (err) {
app.messages.action((err && err.message) || 'Failed to add member', $('#groups-tab-container'), 'danger');
}
}
async function removeResourceGroupMember(groupCn, uid, resourceId) {
const confirmed = await app.messages.confirm('Remove "' + uid + '" from "' + groupCn + '"?', $('#groups-tab-container'), 'warning');
if (!confirmed) return;
try {
const data = await app.api.delete('group/' + groupCn + '/' + uid);
await refreshGroupsUI(resourceId);
app.messages.action(data.message, $('#groups-tab-container'), 'success');
} catch (err) {
app.messages.action((err && err.message) || 'Failed to remove member', $('#groups-tab-container'), 'danger');
}
}
function refreshEdgesUI(resourceId) {
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
const $list = $('#edges-list').empty();
if (!myEdges.length) {
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
}
for (const e of myEdges) {
const isParent = e.parentId === resourceId;
const targetId = isParent ? e.childId : e.parentId;
const target = resourcesById[targetId];
if (!target) continue;
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
const $span = $('<span></span>');
$span.append(isParent
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
$li.append($span);
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
.on('click', function(){ removeEdge(e.id); })
.appendTo($li);
$list.append($li);
}
const $target = $('#new-edge-target');
$target.empty().append('<option value="">-- Select Resource --</option>');
Object.values(resourcesById).forEach(r => {
if (r.id !== resourceId) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
}
});
}
async function openEditModal(id) {
const r = resourcesById[id];
if (!r) return;
openResourceModal('Edit Resource', id);
$('#res-id').val(r.id);
$('#res-name').val(r.name);
$('#res-slug').val(r.slug);
$('#res-kind').val(r.kind);
$('#res-description').val(r.description || '');
$('#res-ip').val(r.metadata.ip || '');
$('#res-address').val(r.metadata.address || '');
$('#res-subtype').val(r.metadata.subType || '');
$('#res-vmid').val(r.metadata.vmid || '');
$('#res-mac').val(r.metadata.macAddress || '');
let osKernel = '';
if (r.metadata.os) osKernel += r.metadata.os;
if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
$('#res-os').val(osKernel);
$('#res-port').val(r.metadata.port || '');
$('#res-external-port').val(r.metadata.externalPort || '');
$('#res-git-repo').val(r.metadata.gitRepo || '');
$('#res-install-path').val(r.metadata.installPath || '');
$('#res-systemd').val(r.metadata.systemdService || '');
$('#res-icon').val(r.metadata.icon || '');
$('#res-tagline').val(r.metadata.tagline || '');
updateIconPreview();
$('#res-is-production').prop('checked', !!r.metadata.isProduction);
$('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
$('#res-is-public').prop('checked', !!r.metadata.isPublic);
$('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
$('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
$('#res-scopes').val((r.metadata.scopes || []).join(' '));
$('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
$('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
$('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
if (r.kind === 'oauth') $('#oauth-rotate-container').show();
else $('#oauth-rotate-container').hide();
// Find parent host
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
toggleFormFields();
refreshGroupsUI(r.id);
refreshEdgesUI(r.id);
refreshChildrenUI(r.id);
await loadLdapGroups();
}
async function saveResource() {
const id = $('#res-id').val();
const data = {
name: $('#res-name').val(),
slug: $('#res-slug').val(),
kind: $('#res-kind').val(),
hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
description: $('#res-description').val(),
metadata: {
subType: $('#res-subtype').val(),
ip: $('#res-ip').val(),
address: $('#res-address').val(),
vmid: $('#res-vmid').val(),
macAddress: $('#res-mac').val(),
os: $('#res-os').val(),
port: $('#res-port').val(),
externalPort: $('#res-external-port').val() || $('#res-port').val(),
gitRepo: $('#res-git-repo').val(),
installPath: $('#res-install-path').val(),
systemdService: $('#res-systemd').val(),
icon: $('#res-icon').val().trim(),
tagline: $('#res-tagline').val().trim(),
isProduction: $('#res-is-production').is(':checked'),
isExternalReachable: $('#res-is-external').is(':checked'),
isPublic: $('#res-is-public').is(':checked'),
isCurrentSite: $('#res-is-current-site').is(':checked')
}
};
if (data.kind === 'oauth') {
data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
data.token_lifetime = {
access_token: Number($('#res-access-ttl').val()) || 3600,
refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
};
}
try {
let res;
if (id) {
res = await app.api.put('directory-admin/resources/' + id, data);
} else {
res = await app.api.post('directory-admin/resources', data);
}
await loadResources();
const savedId = id || (res.results && res.results.id);
if (savedId) setTimeout(function(){ app.util.revealItem($('#resource-row-' + savedId)); }, 400);
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
// Deliberately no app.modal.close() before this -- app.modal is a
// singleton, and close() immediately followed by open() in the same
// tick collides with Bootstrap's hide-transition guard (show()
// silently no-ops while _isTransitioning is still true from the
// just-started hide()). open() alone already overwrites the
// (already-visible) modal's content in place. The await above made
// this race unlikely to lose in practice, but not guaranteed to.
app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
} else {
app.modal.close();
}
} catch (err) {
console.error(err);
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
}
}
async function rotateSecret() {
const id = $('#res-id').val();
if (!id) return;
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
if (!ok) return;
try {
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
} catch (err) {
console.error(err);
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
}
}
async function addGroup() {
const resourceId = $('#res-id').val();
const groupCn = $('#new-group-cn').val().trim();
const accessLevel = $('#new-group-level').val();
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
try {
const res = await app.api.post('directory-admin/groups', {
resourceId,
groupCn,
accessLevel
});
allGroups.push(res.results);
refreshGroupsUI(resourceId);
$('#new-group-cn').val('');
} catch (err) {
console.error(err);
app.messages.action('Failed to add group', app.modal.body(), 'danger');
}
}
async function removeGroup(id) {
try {
await app.api.delete('directory-admin/groups/' + id);
allGroups = allGroups.filter(g => g.id !== id);
refreshGroupsUI($('#res-id').val());
} catch (err) {
console.error(err);
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
}
}
async function addEdge() {
const resourceId = $('#res-id').val();
const dir = $('#new-edge-dir').val();
const targetId = $('#new-edge-target').val();
const relation = $('#new-edge-relation').val().trim() || 'hosts';
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
const data = { relation };
if (dir === 'parent') {
data.parentId = resourceId;
data.childId = targetId;
} else {
data.parentId = targetId;
data.childId = resourceId;
}
try {
const res = await app.api.post('directory-admin/edges', data);
allEdges.push(res.results);
refreshEdgesUI(resourceId);
$('#new-edge-target').val('');
await loadData();
} catch (err) {
console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
}
}
async function removeEdge(id) {
try {
await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val());
await loadData();
} catch (err) {
console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
}
}
async function deleteResource(id) {
// 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
// somewhere actually visible.
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');
if (!ok) return;
try {
await app.api.delete('directory-admin/resources/' + id);
await loadResources();
} catch (err) {
console.error(err);
app.messages.action('Failed to delete', $target, 'danger');
}
}
// --- DISCOVERY SCRIPTS ---
let allDiscoveryResources = [];
function loadDiscoveryResources() {
app.api.get('discovery/resources', function(err, res) {
if(err) {
$('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
return;
}
allDiscoveryResources = res.results || [];
renderDiscoveryTable();
});
}
function renderDiscoveryTable() {
const search = $('#discovery-search-filter').val().toLowerCase();
const filtered = allDiscoveryResources.filter(r => {
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
return true;
});
$.scope.discoveryResources.empty();
for(const r of filtered) {
$.scope.discoveryResources.push(r);
}
if(filtered.length === 0) {
$('#discovery-list').hide();
$('#discovery-empty-state').show();
} else {
$('#discovery-list').show();
$('#discovery-empty-state').hide();
}
}
function promoteResource(slug) {
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
if(err) {
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
return;
}
const resource = allDiscoveryResources.find(r => r.slug === slug);
if(resource) {
resource.metadata = resource.metadata || {};
resource.metadata.managed = true;
}
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
renderDiscoveryTable();
loadResources(); // Also update directory tab
});
}
// --- THETA AGENT INSTALL MODAL & WIZARD ---
function generateRandomHexToken(byteLen) {
const arr = new Uint8Array(byteLen || 16);
(window.crypto || window.msCrypto).getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
function regenerateAgentToken(inputId) {
const newToken = generateRandomHexToken(16);
$('#' + inputId).val(newToken);
if (inputId === 'agent-quick-token') $('#agent-custom-token').val(newToken);
else $('#agent-quick-token').val(newToken);
updateAgentCommands();
}
function updateAgentCommands() {
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
const quickToken = $('#agent-quick-token').val() || '';
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`;
$('#agent-quick-command').text(quickCmd);
const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
const customToken = $('#agent-custom-token').val() || '';
const customLocation = $('#agent-custom-location').val() || 'default';
const telemetry = $('#cap-telemetry').is(':checked');
const configureLdap = $('#cap-configure-ldap').is(':checked');
const reboot = $('#cap-reboot').is(':checked');
const arbitraryBash = $('#cap-arbitrary-bash').is(':checked');
const servicesRaw = $('#cap-services').val() || '';
const servicesList = servicesRaw.split(',').map(s => s.trim()).filter(Boolean);
const servicesYaml = servicesList.length > 0
? '[' + servicesList.map(s => `"${s}"`).join(', ') + ']'
: '[]';
const yamlStr = [
`server_url: "${customUrl}"`,
`auth_token: "${customToken}"`,
`location: "${customLocation}"`,
`capabilities:`,
` telemetry: ${telemetry}`,
` configure_ldap: ${configureLdap}`,
` reboot: ${reboot}`,
` service_control: ${servicesYaml}`,
` arbitrary_bash: ${arbitraryBash}`
].join('\n');
$('#agent-yaml-preview').text(yamlStr);
try {
const b64Config = btoa(yamlStr);
const customCmd = `curl -fsSL ${customUrl}/resources/theta-agent/install.sh | sh -s -- "${b64Config}"`;
$('#agent-custom-command').text(customCmd);
} catch (e) {
$('#agent-custom-command').text('Error encoding config to Base64');
}
}
function copyAgentCommand(elementId, btnId) {
const text = $('#' + elementId).text();
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const $btn = $('#' + btnId);
const origHtml = $btn.html();
$btn.html('<i class="fa-solid fa-check me-1"></i> Copied!').removeClass('btn-success').addClass('btn-outline-success');
setTimeout(() => {
$btn.html(origHtml).removeClass('btn-outline-success').addClass('btn-success');
}, 2000);
}).catch(err => {
app.messages.toast('Failed to copy: ' + err, 'danger');
});
}
function openAgentInstallModal() {
const currentOrigin = window.location.origin;
const initialToken = generateRandomHexToken(16);
const bodyHtml = `
<div class="mb-3 p-3 bg-light rounded border">
<div class="d-flex align-items-center">
<i class="fa-solid fa-shield-halved fa-2x text-primary me-3"></i>
<div>
<h6 class="mb-0 fw-bold">Theta Agent Endpoint Management Daemon</h6>
<small class="text-muted">A 2-way Command & Control (C2) daemon that streams real-time telemetry and enables secure, capability-controlled management operations on Linux hosts.</small>
</div>
</div>
</div>
<ul class="nav nav-pills mb-3" id="agent-install-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-quick-btn" data-bs-toggle="pill" data-bs-target="#tab-quick-pane" type="button" role="tab">
<i class="fa-solid fa-bolt me-1"></i> Quick Install
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-custom-btn" data-bs-toggle="pill" data-bs-target="#tab-custom-pane" type="button" role="tab">
<i class="fa-solid fa-sliders me-1"></i> Custom Config Wizard
</button>
</li>
</ul>
<div class="tab-content" id="agent-install-tab-content">
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-bold mb-1">SSO Server URL</label>
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold mb-1">Host Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-quick-token')" title="Regenerate Token">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
</div>
</div>
<label class="form-label small fw-bold mb-1">Run this command on the target host (as root):</label>
<div class="position-relative mb-2">
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-quick-command" style="user-select: all;"></pre>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-success" id="btn-copy-quick" onclick="copyAgentCommand('agent-quick-command', 'btn-copy-quick')">
<i class="fa-solid fa-copy me-1"></i> Copy Quick Install Command
</button>
</div>
</div>
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-bold mb-1">SSO Server URL</label>
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div>
<div class="col-md-4">
<label class="form-label small fw-bold mb-1">Host Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-custom-token')" title="Regenerate Token">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">Location Identifier</label>
<input type="text" id="agent-custom-location" class="form-control form-control-sm" placeholder="e.g. dc-01-rack-12" value="default" oninput="updateAgentCommands()">
</div>
</div>
<div class="card bg-light border mb-3">
<div class="card-header py-2 bg-light fw-bold small"><i class="fa-solid fa-key me-1"></i> Capability Matrix (Local-First Security Controls)</div>
<div class="card-body py-2">
<div class="row g-2">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-telemetry" checked onchange="updateAgentCommands()">
<label class="form-check-label small" for="cap-telemetry"><strong>Telemetry</strong> <span class="text-muted">(CPU, RAM, Disk, ZFS stats)</span></label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-configure-ldap" checked onchange="updateAgentCommands()">
<label class="form-check-label small" for="cap-configure-ldap"><strong>Configure LDAP</strong> <span class="text-muted">(SSSD config & SSH keys)</span></label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-reboot" onchange="updateAgentCommands()">
<label class="form-check-label small" for="cap-reboot"><strong>Reboot</strong> <span class="text-muted">(remote system reboot)</span></label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-arbitrary-bash" onchange="updateAgentCommands()">
<label class="form-check-label small text-danger" for="cap-arbitrary-bash"><strong>Arbitrary Bash</strong> <span class="text-muted">(remote root execution)</span></label>
</div>
</div>
<div class="col-12 mt-2">
<label class="form-label small fw-bold mb-1">Service Control Allowlist <span class="text-muted font-normal">(comma-separated services, e.g. nginx, gitea, sssd)</span></label>
<input type="text" id="cap-services" class="form-control form-control-sm" placeholder="nginx, docker, sssd" oninput="updateAgentCommands()">
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs nav-tabs-sm mb-2" id="preview-sub-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active py-1 px-3 small" id="subtab-cmd-btn" data-bs-toggle="tab" data-bs-target="#subtab-cmd-pane" type="button">Base64 Install Command</button>
</li>
<li class="nav-item">
<button class="nav-link py-1 px-3 small" id="subtab-yaml-btn" data-bs-toggle="tab" data-bs-target="#subtab-yaml-pane" type="button">Generated agent.yml</button>
</li>
</ul>
<div class="tab-content mb-2">
<div class="tab-pane fade show active" id="subtab-cmd-pane" role="tabpanel">
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-custom-command" style="user-select: all;"></pre>
</div>
<div class="tab-pane fade" id="subtab-yaml-pane" role="tabpanel">
<pre class="bg-light text-dark p-3 rounded border font-monospace small mb-0" id="agent-yaml-preview"></pre>
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-success" id="btn-copy-custom" onclick="copyAgentCommand('agent-custom-command', 'btn-copy-custom')">
<i class="fa-solid fa-copy me-1"></i> Copy Base64 Command
</button>
</div>
</div>
</div>
`;
app.modal.open({
title: 'Install Theta Agent',
bodyHtml: bodyHtml,
size: 'lg'
});
updateAgentCommands();
}
var discoveryPlugins = [];
function loadDiscoveryPlugins() {
app.api.get('plugins', function(err, res) {
if (err) return;
discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
renderDiscoveryPlugins();
});
}
function renderDiscoveryPlugins() {
const $list = $('#discovery-plugins-list').empty();
if (discoveryPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
return;
}
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>
</div>
</div>
</div>
`;
$list.append(card);
});
}
// "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 {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function runDiscoveryPluginNow(id) {
try {
await app.api.post(`plugins/${id}/run`, {});
app.messages.toast('Enqueued discovery plugin run', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error running plugin: ' + e.message, 'danger');
}
}
var discoveryPluginTypes = [];
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
// 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
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
// tokenId / tokenSecret) are collected at create time.
var DP_CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function dpCronKeyFor(cron) {
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function dpCronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = dpCronKeyFor(current);
var opts = DP_CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="dpOnCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + current + '"' + rawStyle + '>';
}
function dpOnCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') { raw.style.display = ''; }
else {
raw.style.display = 'none';
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function dpCronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
function dpConfigFormHtml(type, prefix) {
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>';
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 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>';
});
return html;
}
function dpCollectConfig(type, prefix) {
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
var schema = t && t.configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
return out;
}
function dpRenderFields() {
var type = document.getElementById('new-plugin-type').value;
document.getElementById('new-plugin-config-fields').innerHTML = dpConfigFormHtml(type, 'np-');
}
function openNewDiscoveryPluginModal() {
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');
if (discoveryPluginTypes.length === 0) {
app.messages.toast('No discovery plugin types available', 'warning');
return;
}
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<select id="new-plugin-type" class="form-select shadow-sm" onchange="dpRenderFields()">${options}</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
<div class="form-text">A slug is derived automatically from the name.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Schedule</label>
${dpCronSelectHtml('np-', '0 * * * *')}
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
</div>
<hr><h6 class="fw-bold">Configuration</h6><div id="new-plugin-config-fields">${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}</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="saveNewDiscoveryPlugin()">Create Plugin</button>
</div>
`;
app.modal.open({
title: 'Configure New Discovery Plugin',
bodyHtml: bodyHtml,
size: 'lg'
});
});
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
const cron = dpCronFromForm('np-');
const enabled = $('#new-plugin-enabled').is(':checked');
const config = dpCollectConfig(type, 'np-');
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
try {
await app.api.post('plugins', {
pluginType: type,
name,
cron,
enabled,
config
});
app.messages.toast('Discovery plugin created successfully!', 'success');
app.modal.close();
loadDiscoveryPlugins();
} catch (e) {
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
}
}
$(document).ready(function(){
loadDiscoveryResources();
loadDiscoveryPlugins();
// Keep the host status dots live: refresh the agent join periodically and on
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
refreshAgents();
setInterval(refreshAgents, 30000);
try {
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
dirAgentSocket.on('agent.telemetry', function(msg){
const a = msg && agentsByToken[msg.token];
if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); }
});
dirAgentSocket.on('agent.discovery', function(msg){
const a = msg && agentsByToken[msg.token];
if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); }
});
} catch (e) { /* socket is optional; periodic refresh still runs */ }
});
</script>
<%- include('bottom') %>