Files
sso-manager-node/nodejs/views/directory.ejs
T

3171 lines
148 KiB
Plaintext

<%- 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">
<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">
<div class="btn-group btn-group-sm shadow-sm" role="group" aria-label="Expand or collapse the whole tree">
<button type="button" class="btn btn-outline-secondary" onclick="expandAllTree()" title="Expand all"><i class="fa-solid fa-angles-down"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="collapseAllTree()" title="Collapse all"><i class="fa-solid fa-angles-up"></i></button>
</div>
<div class="form-check form-switch form-check-inline ms-1 me-1">
<input class="form-check-input" type="checkbox" id="toggle-secrets-only" onchange="renderTable()">
<label class="form-check-label small text-muted" for="toggle-secrets-only" title="Filter tree to show only resources with stored secrets"><i class="fa-solid fa-key text-warning me-1"></i>With Secrets</label>
</div>
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources or keys..." onkeyup="renderTable()" style="width: 210px;">
<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="openAddSiteModal()">
<i class="fas fa-plus"></i> Add Site
</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}}" data-depth="{{depth}}">
<td class="ps-3">
{{{indentHtml}}}
{{{caretHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<a href="#" class="text-reset text-decoration-none me-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
</a>
<span class="badge bg-secondary me-1">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#hasSecret}}<span class="badge bg-warning text-dark me-1" title="Has stored secrets in OpenBao"><i class="fa-solid fa-key me-1"></i>Secret</span>{{/hasSecret}}
{{#metadata.isProduction}}<span class="badge bg-danger me-1">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info me-1">Dev</span>{{/metadata.isProduction}}
</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>
<div class="text-muted small font-monospace">
{{#metadata.vmid}}<span class="me-2" title="Guest ID on the hypervisor">#{{metadata.vmid}}</span>{{/metadata.vmid}}
<span title="Directory slug">{{slug}}</span>
</div>
{{#metadata.sourceId}}
<div class="text-muted small font-monospace" title="Identifier at the discovery source">
<i class="fa-solid fa-fingerprint pe-1"></i>{{metadata.sourceId}}
</div>
{{/metadata.sourceId}}
</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>
{{#displayIp}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{displayIp}}</div>{{/displayIp}}
{{^displayIp}}<span class="text-muted small fst-italic">Unknown IP</span>{{/displayIp}}
{{#metadata.interfaces.length}}
<div class="mt-1 small text-muted">
{{#metadata.interfaces}}
<div>
<i class="fa-solid fa-microchip pe-1"></i>
{{#mac}}<span class="font-monospace">{{mac}}</span>{{/mac}}{{^mac}}<span class="fst-italic">no MAC</span>{{/mac}}
{{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}
{{#name}}<span class="text-black-50">{{name}}</span>{{/name}}
</div>
{{/metadata.interfaces}}
</div>
{{/metadata.interfaces.length}}
{{^metadata.interfaces.length}}
{{#metadata.macAddress}}<div class="mt-1 small text-muted"><i class="fa-solid fa-microchip pe-1"></i> <span class="font-monospace">{{metadata.macAddress}}</span></div>{{/metadata.macAddress}}
{{/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 me-1" onclick="promoteResource('{{slug}}')" title="Promote to Managed Directory Host">
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
</button>
<button class="btn btn-sm btn-outline-success me-1" onclick="openMergeModal('{{id}}', '{{name}}')" title="Merge into Existing Resource">
<i class="fa-solid fa-code-merge"></i> Merge
</button>
<button class="btn btn-sm btn-outline-danger" onclick="ignoreDiscoveredResource('{{id}}')" title="Ignore / Dismiss">
<i class="fa-solid fa-eye-slash"></i> Ignore
</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>
<!-- 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>
</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>
`;
var secretsTabHtml = `
<div class="mb-3" id="secrets-tab-container">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-0"><i class="fa-solid fa-vault text-warning me-2"></i>Resource Secrets (OpenBao KV Engine)</h6>
<small class="text-muted">Encrypted key-value secrets stored in OpenBao under <code>secret/data/resources/&lt;slug&gt;/conf</code></small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="refreshResourceSecrets()"><i class="fa-solid fa-sync me-1"></i> Refresh</button>
</div>
<div class="secrets-action-msg mb-2" style="display:none"></div>
<div class="table-responsive shadow-sm rounded border mb-3">
<table class="table table-hover align-middle mb-0" id="secrets-table">
<thead class="table-dark">
<tr>
<th style="width: 35%;">Secret Key</th>
<th>Status / Security</th>
<th style="width: 240px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody id="secrets-table-body">
<tr><td colspan="3" class="text-center text-muted py-3">Loading secrets...</td></tr>
</tbody>
</table>
</div>
<!-- Add / Generate Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3 mb-3">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-plus-circle text-primary me-1"></i> Add or Generate Secret Key</h6>
<div class="row g-2 align-items-center mb-2">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Secret Key Name (e.g. <code>DB_PASSWORD</code>)</label>
<input type="text" class="form-control form-control-sm font-monospace" id="new-secret-key" placeholder="DB_PASSWORD" onkeyup="validateSecretKeyInput(this)">
<div class="invalid-feedback small">Only letters, numbers, and underscores allowed (e.g. DB_PASSWORD).</div>
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Secret Value</label>
<input type="text" class="form-control form-control-sm font-monospace" id="new-secret-val" placeholder="Enter value or click Generate">
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-primary w-100" id="btn-add-secret" onclick="addSecretRow()"><i class="fa-solid fa-save me-1"></i> Save Secret</button>
</div>
</div>
<div class="row g-2 align-items-center mt-1">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Generator Length</label>
<select class="form-select form-select-sm" id="gen-secret-length">
<option value="8">8 characters</option>
<option value="12">12 characters</option>
<option value="16">16 characters</option>
<option value="24">24 characters</option>
<option value="32" selected>32 characters (Default)</option>
<option value="48">48 characters</option>
<option value="64">64 characters</option>
<option value="128">128 characters</option>
</select>
</div>
<div class="col-md-8 text-end pt-3">
<button class="btn btn-sm btn-outline-success" onclick="generateSecretValue()"><i class="fa-solid fa-bolt me-1"></i> Generate Secret into Field</button>
</div>
</div>
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
</div>
</div>
<!-- Inherit Parent Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3" id="inherit-secret-card">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-diagram-project text-info me-1"></i> Inherit Secret from Parent Resource</h6>
<div class="row g-2 align-items-center">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Child Secret Key Name</label>
<input type="text" class="form-control form-control-sm font-monospace" id="inherit-child-key" placeholder="DB_HOST">
</div>
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Parent Resource Secret</label>
<select class="form-select form-select-sm" id="inherit-parent-select"></select>
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-outline-info w-100" id="btn-inherit-secret" onclick="inheritParentSecret()"><i class="fa-solid fa-link me-1"></i> Inherit Secret</button>
</div>
</div>
</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: 'secrets', label: 'Secrets & OpenBao', bodyHtml: secretsTabHtml},
{id: 'agent', label: 'Agent', bodyHtml: agentTabHtml(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, #sw-modal-tab-secrets-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 = [];
var accessSummary = {};
var promoteSlug = null;
$(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);
}
});
var agentsByHost = {};
var agentsByResource = {};
var agentsById = {};
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'),
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
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) {
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();
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(){});
} catch (err) {
console.error(err);
app.messages.toast('Failed to load data', 'danger');
}
}
function indexAgents(agents) {
agentsByHost = {};
agentsByResource = {};
agentsById = {};
for (const a of agents || []) {
agentsById[a.id] = a;
if (a.resourceId) agentsByResource[a.resourceId] = a;
const hn = ((a.lastDiscovery && a.lastDiscovery.hostname) || a.name || '').toLowerCase();
if (hn && !agentsByHost[hn]) agentsByHost[hn] = 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() : ''; }
function attachAgentStatus(n) {
n.isHost = true;
const name = (n.name || '').toLowerCase();
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
const a = agentsByResource[n.id] || agentsByHost[name] || (slug && agentsByHost[slug]);
n.agent = a || null;
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
if (!a) {
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
n.agentColor = '#adb5bd'; n.agentStatusTitle = 'No theta-agent enrolled'; return;
}
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
if (!a.isOnline) {
const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
}
const t = a.lastTelemetry || {};
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';
}
// Agent tab body for the resource modal.
function agentTabHtml(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 telemetry and issue control commands.</p></div>';
}
const d = agent.lastDiscovery || {};
const t = agent.lastTelemetry || {};
const fmtNum = (n) => (n == null || isNaN(n)) ? '0.00' : Number(n).toFixed(2);
const fmtSize = (bytes) => {
if (!bytes || bytes <= 0) return '0.00 B';
const gib = bytes / (1024 * 1024 * 1024);
if (gib >= 1) return fmtNum(gib) + ' GiB';
const mib = bytes / (1024 * 1024);
return fmtNum(mib) + ' MiB';
};
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) ? fmtNum(t.gpu_usage_percent) + '%' : 'N/A';
const lastSeenIso = agent.lastSeen || (agent.last_seen ? new Date(agent.last_seen * 1000).toISOString() : '');
const lastSeenStr = timeAgo(lastSeenIso) || 'never';
// RAM details
const ram = t.ram_details || d.ram_details || {};
const totalRamBytes = ram.total_bytes || (d.ram_total_gb ? d.ram_total_gb * 1024 * 1024 * 1024 : 0);
const usedRamBytes = ram.used_bytes || (totalRamBytes * (t.ram_usage_percent || 0) / 100);
const bufRamBytes = ram.buffers_cache_bytes || 0;
const freeRamBytes = ram.free_bytes || Math.max(0, totalRamBytes - usedRamBytes - bufRamBytes);
const usedRamPct = ram.used_percent != null ? ram.used_percent : (t.ram_usage_percent || 0);
const bufRamPct = ram.buffers_cache_percent != null ? ram.buffers_cache_percent : 0;
const freeRamPct = ram.free_percent != null ? ram.free_percent : Math.max(0, 100 - usedRamPct - bufRamPct);
// CPU details
const cpuDet = t.cpu_details || d.cpu_details || {};
const cpuModel = cpuDet.model || d.cpu || 'Unknown CPU';
const cpuCores = cpuDet.cores || 'N/A';
const cpuThreads = cpuDet.threads || 'N/A';
const cpuSpeed = cpuDet.mhz ? (cpuDet.mhz >= 1000 ? (cpuDet.mhz / 1000).toFixed(2) + ' GHz' : cpuDet.mhz.toFixed(0) + ' MHz') : '';
// Disks
const disks = t.disks || d.disks || [];
let disksHtml = '';
if (disks.length > 0) {
disksHtml = `<div class="table-responsive"><table class="table table-sm text-center small mb-0"><thead><tr><th>Mount</th><th>Type</th><th>FS</th><th>Usage</th><th>Total</th></tr></thead><tbody>` +
disks.map(dk => `<tr>
<td><code>${esc(dk.mountpoint)}</code></td>
<td><span class="badge bg-outline-secondary border text-dark">${esc(dk.drivetype || 'Disk')}</span></td>
<td><span class="badge bg-light text-dark border">${esc(dk.fstype || 'N/A')}</span></td>
<td>${fmtNum(dk.usage_percent)}%</td>
<td>${fmtSize(dk.total_bytes)}</td>
</tr>`).join('') + `</tbody></table></div>`;
} else {
disksHtml = `<div class="small">Disk Usage: <strong>${fmtNum(t.disk_usage_percent ?? 0)}%</strong> ${bar(t.disk_usage_percent)}</div>`;
}
const agentVer = d.version || t.version || agent.version || 'v1.7.0';
const loggedUsers = t.logged_users || d.logged_users || agent.loggedUsers || [];
let loggedUsersHtml = '';
if (loggedUsers.length > 0) {
loggedUsersHtml = `<div class="table-responsive"><table class="table table-sm small mb-0 align-middle">
<thead><tr><th>User</th><th>Terminal</th><th>Host / IP</th><th>Session Time</th></tr></thead><tbody>` +
loggedUsers.map(u => `<tr>
<td><i class="fa-solid fa-user text-primary me-1"></i><strong>${esc(u.user)}</strong></td>
<td><code class="text-muted">${esc(u.terminal || 'tty')}</code></td>
<td>${esc(u.host || 'local')}</td>
<td class="text-muted">${u.started ? moment(u.started * 1000).fromNow() : 'active'}</td>
</tr>`).join('') + `</tbody></table></div>`;
} else {
loggedUsersHtml = `<div class="small text-muted p-2"><i class="fa-solid fa-user-slash me-1"></i>No active logged-in user sessions.</div>`;
}
return `<div class="p-3">
<div class="mb-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">
${esc(agent.name || agent.hostname || 'unknown')} ${online}
<span class="badge bg-dark ms-2 font-monospace" title="Theta Agent Binary Version"><i class="fa-solid fa-code-branch me-1"></i>${esc(agentVer)}</span>
</h5>
<small class="text-muted">Last seen ${lastSeenStr}</small>
</div>
<!-- Memory Card (Matching user screenshot design) -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-memory me-1"></i> Memory</h6>
</div>
<div class="card-body p-2">
<div class="p-2 mb-1 rounded fw-bold" style="background-color: #d0e1fd; color: #084298;">
Total: <span>${fmtSize(totalRamBytes)}</span>
</div>
<div class="p-2 mb-1 rounded" style="background-color: #f8d7da; color: #842029;">
Used: <strong>${fmtSize(usedRamBytes)}</strong> <em>${fmtNum(usedRamPct)}%</em>
</div>
<div class="p-2 mb-1 rounded" style="background-color: #e2e3e5; color: #41464b;">
Buffer+Cache: <strong>${fmtSize(bufRamBytes)}</strong> <em>${fmtNum(bufRamPct)}%</em>
</div>
<div class="p-2 mb-1 rounded" style="background-color: #d1e7dd; color: #0f5132;">
Free: <strong>${fmtSize(freeRamBytes)}</strong> <em>${fmtNum(freeRamPct)}%</em>
</div>
<div class="progress mt-2" style="height: 14px; border-radius: 6px; overflow: hidden;">
<div class="progress-bar bg-danger" role="progressbar" style="width: ${Math.max(0, Math.min(100, usedRamPct))}%"></div>
<div class="progress-bar bg-secondary" role="progressbar" style="width: ${Math.max(0, Math.min(100, bufRamPct))}%"></div>
<div class="progress-bar bg-success" role="progressbar" style="width: ${Math.max(0, Math.min(100, freeRamPct))}%"></div>
</div>
</div>
</div>
<!-- CPU Card -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-microchip me-1"></i> CPU</h6>
</div>
<div class="card-body p-3">
<div class="mb-2"><strong>Model:</strong> ${esc(cpuModel)}</div>
<div class="mb-2"><strong>Specs:</strong> ${esc(cpuCores)} Cores / ${esc(cpuThreads)} Threads ${cpuSpeed ? '@ ' + esc(cpuSpeed) : ''}</div>
<div>Usage: <strong>${fmtNum(t.cpu_usage_percent ?? 0)}%</strong> ${bar(t.cpu_usage_percent)}</div>
</div>
</div>
<!-- Disks Card -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-hard-drive me-1"></i> Physical Disks & Filesystems</h6>
</div>
<div class="card-body p-2">
${disksHtml}
</div>
</div>
<!-- Logged-in Users Card -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-users me-1"></i> Active Logged-in Users</h6>
</div>
<div class="card-body p-2">
${loggedUsersHtml}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">GPU <strong>${gpu}</strong></div>
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
</div>
<!-- Desktop Controls Card -->
<div class="card mb-3 border-info shadow-sm">
<div class="card-header bg-info text-dark py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-desktop me-1"></i> Desktop Session & Power Controls</h6>
</div>
<div class="card-body p-3 d-flex flex-wrap gap-2">
<button class="btn btn-outline-dark btn-sm" onclick="agentDesktopControl('${agent.id}', 'lock')"><i class="fa-solid fa-lock me-1"></i> Lock Session</button>
<button class="btn btn-outline-secondary btn-sm" onclick="agentDesktopControl('${agent.id}', 'display_off')"><i class="fa-solid fa-tv me-1"></i> Display Off</button>
<button class="btn btn-outline-warning btn-sm" onclick="agentDesktopControl('${agent.id}', 'logout')"><i class="fa-solid fa-right-from-bracket me-1"></i> Log Out User</button>
<button class="btn btn-outline-primary btn-sm" onclick="agentDesktopControl('${agent.id}', 'sleep')"><i class="fa-solid fa-moon me-1"></i> Sleep Host</button>
</div>
</div>
<!-- Host Power Controls -->
<div class="card mb-3 border-danger shadow-sm">
<div class="card-header bg-danger text-white py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-power-off me-1"></i> Host Power Operations</h6>
</div>
<div class="card-body p-3 d-flex gap-2">
<button class="btn btn-outline-danger btn-sm" onclick="agentReboot('${agent.id}')"><i class="fa-solid fa-arrows-rotate me-1"></i> Reboot Host</button>
<button class="btn btn-danger btn-sm" onclick="agentShutdown('${agent.id}')"><i class="fa-solid fa-power-off me-1"></i> Shutdown Host</button>
</div>
</div>
<!-- Systemd Service Manager -->
<div class="card mb-3 border-primary shadow-sm">
<div class="card-header bg-primary text-white py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-gear me-1"></i> Systemd Service Manager</h6>
</div>
<div class="card-body p-3">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text">Service Name</span>
<input type="text" class="form-control" id="sysd-service-${agent.id}" placeholder="e.g. nginx, sshd, docker" value="sshd">
<button class="btn btn-outline-primary" onclick="manageService('${agent.id}', 'status')">Status</button>
<button class="btn btn-outline-success" onclick="manageService('${agent.id}', 'start')">Start</button>
<button class="btn btn-outline-warning" onclick="manageService('${agent.id}', 'restart')">Restart</button>
<button class="btn btn-outline-danger" onclick="manageService('${agent.id}', 'stop')">Stop</button>
</div>
<div id="sysd-output-${agent.id}" class="d-none mt-2">
<pre class="bg-dark text-light p-2 rounded small mb-0" style="max-height: 200px; overflow-y: auto;" id="sysd-text-${agent.id}"></pre>
</div>
</div>
</div>
<hr><h6>Discovery & Metadata</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>
<hr><h6>Capabilities</h6>
<div class="small">${capabilitiesHtml(d.capabilities)}</div>
</div>`;
}
// Render the agent's enabled capabilities (reported in its discovery frame) as
// green/gray badges. service_control is a list, so it renders as its own line.
function capabilitiesHtml(caps) {
caps = caps || {};
const badge = (name, on) => `<span class="badge ${on ? 'bg-success' : 'bg-secondary'} me-1 mb-1">${esc(name)}</span>`;
const bools = [
['Telemetry', caps.telemetry],
['LDAP config', caps.configure_ldap],
['LDAP tunnel', caps.ldap_tunnel],
['Secrets', caps.secrets],
['IAM', caps.iam],
['Reboot', caps.reboot],
['Bash', caps.arbitrary_bash],
];
const sc = Array.isArray(caps.service_control) ? caps.service_control : [];
const scLine = sc.length
? `<div class="mt-1 text-muted">Service control: ${esc(sc.join(', '))}</div>`
: '';
return bools.map(([n, on]) => badge(n, !!on)).join('') + scLine;
}
async function agentReboot(agentId) {
const ok = await app.messages.confirm('Are you sure you want to REBOOT this host?');
if (!ok) return;
app.api.post(`agent/nodes/${agentId}/command`, { command: 'reboot', isHighRisk: true }, function(err, res) {
if (err) return app.messages.toast('Reboot failed: ' + (err.message || err), 'danger');
app.messages.toast('Reboot command sent to host', 'success');
});
}
async function agentShutdown(agentId) {
const ok = await app.messages.confirm('Are you sure you want to SHUTDOWN this host?');
if (!ok) return;
app.api.post(`agent/nodes/${agentId}/command`, { command: 'shutdown', isHighRisk: true }, function(err, res) {
if (err) return app.messages.toast('Shutdown failed: ' + (err.message || err), 'danger');
app.messages.toast('Shutdown command sent to host', 'success');
});
}
function manageService(agentId, action) {
const service = ($(`#sysd-service-${agentId}`).val() || '').trim();
if (!service) return app.messages.toast('Service name required', 'warning');
const outBox = $(`#sysd-output-${agentId}`);
const outText = $(`#sysd-text-${agentId}`);
outBox.removeClass('d-none');
outText.text(`Executing systemctl ${action} ${service}...`);
app.api.post(`agent/nodes/${agentId}/command`, {
command: 'systemd_action',
payload: { action, service },
isHighRisk: action !== 'status'
}, function(err, res) {
if (err) {
outText.text('Error: ' + (err.message || JSON.stringify(err)));
return;
}
outText.text(`Command '${action}' sent for service '${service}'. Check output or logs.`);
});
}
// 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();
const secretsOnly = $('#toggle-secrets-only').is(':checked');
let filtered = rawResources.filter(r => {
if (secretsOnly && !r.hasSecret) {
return false;
}
if (!filter) return true;
const secretKeysStr = (r.secretKeys || []).join(' ').toLowerCase();
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) ||
secretKeysStr.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);
}
});
// Rows are emitted depth-first, so a node's descendants are exactly the
// rows that follow it until depth drops back to its own. `data-depth` is
// what applyTreeCollapse() below walks -- that ordering is the whole
// mechanism, so keep the traversal depth-first if you change this.
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.depth = depth;
// A leaf gets a spacer of the same width, so names stay aligned down
// the column instead of jittering by whether a row has children.
n.caretHtml = n.children.length
? '<button type="button" class="btn btn-link btn-sm p-0 me-1 text-reset tree-caret" '
+ 'onclick="toggleTreeNode(\'' + n.id + '\'); return false;" '
+ 'aria-label="Expand or collapse ' + escapeHtmlAttr(n.name || '') + '" '
+ 'title="Expand/collapse"><i class="fa-solid fa-chevron-down fa-fw"></i></button>'
: '<span class="d-inline-block me-1" style="width:1.1rem"></span>';
n.childCount = n.children.length;
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);
}
applyTreeCollapse();
}
// ── Collapsible tree ───────────────────────────────────────────────────────
// Which nodes are collapsed, by resource id. Persisted so the shape of the
// tree survives a refresh (and the Directory self-heal reload that follows
// most edits) -- a tree that re-expands every time is worse than no tree.
var TREE_COLLAPSE_KEY = 'directory.collapsedNodes';
function loadCollapsed() {
try {
const raw = localStorage.getItem(TREE_COLLAPSE_KEY);
return new Set(raw ? JSON.parse(raw) : []);
} catch (e) { return new Set(); }
}
function saveCollapsed(set) {
try { localStorage.setItem(TREE_COLLAPSE_KEY, JSON.stringify([...set])); } catch (e) {}
}
function escapeHtmlAttr(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Hide every row beneath a collapsed node and point its caret sideways.
// Rows are in depth-first order, so "beneath" is the run of following rows
// with a greater depth. A node inside an already-hidden run stays hidden
// regardless of its own state, which is what makes nesting work.
function applyTreeCollapse() {
const $rows = $('#resources-list tr');
// While a search is active every match must be visible, even one sitting
// under a collapsed ancestor -- otherwise searching silently returns
// nothing and looks broken. The collapsed set is left untouched, so the
// tree springs back to its saved shape as soon as the box is cleared.
if (($('#search-filter').val() || '').trim()) {
$rows.show();
$rows.find('.tree-caret i').removeClass('fa-chevron-right').addClass('fa-chevron-down');
return;
}
const collapsed = loadCollapsed();
let hideBelowDepth = null;
$rows.each(function() {
const $row = $(this);
const depth = parseInt($row.attr('data-depth') || '0', 10);
const id = ($row.attr('id') || '').replace('resource-row-', '');
if (hideBelowDepth !== null && depth > hideBelowDepth) {
$row.hide();
return; // still inside a collapsed subtree; its own state is moot
}
hideBelowDepth = null;
$row.show();
// 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)) {
$caret.addClass('tree-caret-collapsed');
hideBelowDepth = depth;
} else {
$caret.removeClass('tree-caret-collapsed');
}
});
}
function toggleTreeNode(id) {
const collapsed = loadCollapsed();
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
saveCollapsed(collapsed);
applyTreeCollapse();
}
function expandAllTree() {
saveCollapsed(new Set());
applyTreeCollapse();
}
// Collapse every row that has children. Reads the ids out of the rendered
// rows rather than the resource list so it can only ever collapse something
// that is actually on screen and actually has a caret.
function collapseAllTree() {
const collapsed = new Set();
$('#resources-list tr').each(function() {
const $row = $(this);
if (!$row.find('.tree-caret').length) return;
collapsed.add(($row.attr('id') || '').replace('resource-row-', ''));
});
saveCollapsed(collapsed);
applyTreeCollapse();
}
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();
}
function openAddSiteModal() {
openAddModal(null, null);
$('#res-kind').val('site');
toggleFormFields();
}
function openAddHostModal(parentId) {
openAddModal(parentId, 'site');
$('#res-kind').val('host');
toggleFormFields();
}
function openAddServiceModal(parentId) {
openAddModal(parentId, 'host');
$('#res-kind').val('service');
toggleFormFields();
}
async function openMergeModal(discoveredId, discName) {
const targets = rawResources.filter(r => r.kind === 'host' || r.kind === 'site' || r.kind === 'service');
let optionsHtml = targets.map(r => `<option value="${r.id}">${esc(r.name)} (${esc(r.kind)}${r.metadata?.subType ? ' - ' + esc(r.metadata.subType) : ''})</option>`).join('');
app.modal.open({
title: `Merge '${esc(discName)}' into Existing Resource`,
size: 'md',
bodyHtml: `
<div class="p-3">
<p class="small text-muted">Select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
<div class="mb-3">
<label class="form-label font-weight-bold">Target Directory Resource</label>
<select class="form-select" id="merge-target-id">
${optionsHtml}
</select>
</div>
</div>
`,
footer: {
buttonsHtml: `
<button class="btn btn-secondary btn-sm" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-success btn-sm" onclick="submitMergeResource('${discoveredId}')"><i class="fa-solid fa-code-merge me-1"></i> Confirm Merge</button>
`
}
});
}
async function submitMergeResource(discoveredId) {
const targetId = $('#merge-target-id').val();
if (!targetId) return;
try {
app.messages.action('Merging discovered resource...', $('#app-modal-body'), 'info');
await app.api.post('directory-admin/discovered/merge', { discoveredId, targetId });
app.messages.action('Resource merged successfully!', null, 'success');
app.modal.close();
loadData();
} catch (err) {
app.messages.action(err.message || 'Failed to merge resource', $('#app-modal-body'), 'danger');
}
}
async function ignoreDiscoveredResource(resourceId) {
const confirmed = await app.messages.confirm('Ignore and dismiss this discovered device?', null, 'warning');
if (!confirmed) return;
try {
await app.api.post('directory-admin/discovered/ignore', { resourceId });
app.messages.action('Discovered device ignored.', null, 'info');
loadData();
} catch (err) {
app.messages.action(err.message || 'Failed to ignore device', null, 'danger');
}
}
async function agentDesktopControl(agentId, action, username = '') {
const confirmed = await app.messages.confirm(`Execute '${action}' desktop control action?`, $('#res-modal'), 'warning');
if (!confirmed) return;
const resourceId = $('#res-id').val();
if (!resourceId) return;
try {
app.messages.action(`Sending '${action}' desktop command to agent...`, $('#res-modal'), 'info');
const res = await app.api.post(`directory-admin/resources/${resourceId}/driver-action`, {
action: 'desktop_control',
subAction: action,
user: username
});
app.messages.action(`Desktop action '${action}' completed successfully!`, $('#res-modal'), 'success');
} catch (err) {
app.messages.action(err.message || 'Desktop action failed', $('#res-modal'), 'danger');
}
}
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);
loadResourceSecrets(r.id);
await loadLdapGroups();
}
var currentResourceSecretsList = [];
var currentParentSecretsList = [];
var rawResourceSecretsMap = {};
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
function validateSecretKeyInput(el) {
const $el = $(el);
const val = $el.val().trim();
if (val && !SECRET_KEY_REGEX.test(val)) {
$el.addClass('is-invalid');
return false;
} else {
$el.removeClass('is-invalid');
return true;
}
}
function generateRandomString(len) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
const bytes = new Uint8Array(len);
window.crypto.getRandomValues(bytes);
let str = '';
for (let i = 0; i < len; i++) {
str += chars[bytes[i] % chars.length];
}
return str;
}
async function loadResourceSecrets(id) {
const $tbody = $('#secrets-table-body').empty();
$tbody.append('<tr><td colspan="3" class="text-center text-muted py-3"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading secrets from OpenBao...</td></tr>');
currentResourceSecretsList = [];
currentParentSecretsList = [];
rawResourceSecretsMap = {};
try {
const res = await app.api.get(`directory-admin/resources/${id}/secrets`);
currentResourceSecretsList = (res && res.secrets) || [];
currentParentSecretsList = (res && res.parentSecrets) || [];
renderSecretsTable();
populateParentSecretsDropdown();
} catch (err) {
$tbody.empty().append(`<tr><td colspan="3" class="text-center text-danger py-3"><i class="fa-solid fa-triangle-exclamation me-2"></i>Failed to load secrets: ${esc(err.message || 'Unknown error')}</td></tr>`);
}
}
function populateParentSecretsDropdown() {
const $card = $('#inherit-secret-card');
const $select = $('#inherit-parent-select').empty();
const $btn = $('#btn-inherit-secret');
$card.show();
if (!currentParentSecretsList || currentParentSecretsList.length === 0) {
$select.append('<option value="">(No parent/ancestor secrets available in OpenBao)</option>');
$btn.prop('disabled', true);
return;
}
$btn.prop('disabled', false);
$select.append('<option value="">-- Select Parent Resource Secret --</option>');
currentParentSecretsList.forEach(p => {
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
});
}
function renderSecretsTable() {
const $tbody = $('#secrets-table-body').empty();
if (currentResourceSecretsList.length === 0) {
$tbody.append('<tr><td colspan="3" class="text-center text-muted py-3">No secrets configured for this resource yet.</td></tr>');
return;
}
currentResourceSecretsList.forEach((s, idx) => {
const $row = $(`
<tr class="secret-row" data-key="${esc(s.key)}">
<td><code class="fw-bold fs-6">${esc(s.key)}</code></td>
<td>
${s.isInherited
? `<span class="badge bg-info text-dark shadow-sm"><i class="fa-solid fa-link me-1"></i>Inherited from ${esc(s.parentSlug || 'Parent')}</span> <small class="text-muted ms-1">(${esc(s.parentKey || s.key)})</small>`
: `<span class="badge bg-success shadow-sm"><i class="fa-solid fa-lock me-1"></i>Configured in OpenBao</span> <span class="badge bg-secondary ms-1"><i class="fa-solid fa-eye-slash me-1"></i>Secret Value Hidden</span>`
}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" onclick="editSecretKey('${esc(s.key)}')" title="Set / Overwrite Value"><i class="fa-solid fa-pen me-1"></i> Edit Value</button>
<button class="btn btn-sm btn-outline-danger ms-1" onclick="deleteSecretKey('${esc(s.key)}')" title="Delete Secret"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`);
$tbody.append($row);
});
}
function generateSecretValue() {
let key = $('#new-secret-key').val().trim();
if (!key) {
key = 'SECRET_KEY';
$('#new-secret-key').val(key);
}
const len = parseInt($('#gen-secret-length').val(), 10) || 32;
const randomSecret = generateRandomString(len);
$('#new-secret-val').val(randomSecret);
$('#gen-secret-notice').show();
}
function editSecretKey(key) {
$('#new-secret-key').val(key);
$('#new-secret-val').val('').focus();
$('#gen-secret-notice').hide();
}
async function addSecretRow() {
const keyEl = $('#new-secret-key')[0];
const key = $('#new-secret-key').val().trim();
const val = $('#new-secret-val').val();
const resourceId = $('#res-id').val();
if (!key) {
app.messages.action('Please enter a secret key name (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'warning');
return;
}
if (!validateSecretKeyInput(keyEl)) {
app.messages.action('Invalid secret key format. Only uppercase/lowercase letters, numbers, and underscores are allowed (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'danger');
return;
}
if (!resourceId) return;
try {
app.messages.action('Saving secret to OpenBao...', $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: { [key]: val || '' } });
app.messages.action(`Secret '${key}' saved to OpenBao successfully!`, $('#secrets-tab-container'), 'success');
$('#new-secret-key').val('');
$('#new-secret-val').val('');
$('#gen-secret-notice').hide();
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to save secret to OpenBao', $('#secrets-tab-container'), 'danger');
}
}
async function inheritParentSecret() {
let childKey = $('#inherit-child-key').val().trim();
const inheritVal = $('#inherit-parent-select').val();
const resourceId = $('#res-id').val();
if (!inheritVal) {
app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning');
return;
}
if (!childKey) {
const parts = inheritVal.split(':');
childKey = parts[parts.length - 1];
}
if (!SECRET_KEY_REGEX.test(childKey)) {
app.messages.action('Invalid child key name. Only letters, numbers, and underscores allowed.', $('#secrets-tab-container'), 'danger');
return;
}
if (!resourceId) return;
try {
app.messages.action('Saving inherited secret to OpenBao...', $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: { [childKey]: inheritVal } });
app.messages.action(`Inherited secret '${childKey}' saved successfully!`, $('#secrets-tab-container'), 'success');
$('#inherit-child-key').val('');
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to save inherited secret', $('#secrets-tab-container'), 'danger');
}
}
async function deleteSecretKey(key) {
const confirmed = await app.messages.confirm(`Delete secret '${key}' from OpenBao?`, $('#secrets-tab-container'), 'danger');
if (!confirmed) return;
const resourceId = $('#res-id').val();
if (!resourceId) return;
try {
app.messages.action(`Deleting secret '${key}' from OpenBao...`, $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { action: 'delete', key });
app.messages.action(`Secret '${key}' deleted successfully from OpenBao.`, $('#secrets-tab-container'), 'success');
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to delete secret from OpenBao', $('#secrets-tab-container'), 'danger');
}
}
function refreshResourceSecrets() {
const resourceId = $('#res-id').val();
if (resourceId) loadResourceSecrets(resourceId);
}
async function saveResource() {
// Promote path: the modal was opened from a discovered inventory row, so
// Save confirms promotion (creates LDAP groups + marks managed) rather than
// a normal resource create/update.
if (promoteSlug) {
const slug = promoteSlug;
promoteSlug = null;
try {
const res = await new Promise((resolve, reject) => {
app.api.post('discovery/promote/' + slug, {}, function(err, r) {
if (err) reject(err); else resolve(r);
});
});
await loadResources();
loadDiscoveryResources();
app.modal.close();
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
} catch (err) {
promoteSlug = slug;
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
}
return;
}
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('');
await loadResources(); // keep the Access column in sync
} 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());
await loadResources(); // keep the Access column in sync
} 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 loadResources();
} 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 loadResources();
} 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) {
// "Unknown IP" was shown for every device whose address is known per-NIC
// rather than in metadata.ip -- which is most of them, since a source
// that enumerates interfaces (UniFi, Proxmox guest agent) fills
// `interfaces[].ip`. Resolve a display address from the NICs so the
// column agrees with the interface list right beneath it.
const meta = r.metadata || {};
const fromNic = (meta.interfaces || []).map(i => i && i.ip).find(Boolean) || null;
r.displayIp = meta.ip || fromNic;
$.scope.discoveryResources.push(r);
}
if(filtered.length === 0) {
$('#discovery-list').hide();
$('#discovery-empty-state').show();
} else {
$('#discovery-list').show();
$('#discovery-empty-state').hide();
}
}
// Promoting a discovered resource opens the resource form pre-filled with the
// discovered data so it can be reviewed before the resource is marked managed
// (and its LDAP groups created). The modal's Save (saveResource) sees
// promoteSlug set and calls the promote endpoint instead of a normal save.
function promoteResource(slug) {
const r = allDiscoveryResources.find(x => x.slug === slug);
if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; }
promoteSlug = slug;
openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden
const m = r.metadata || {};
$('#res-name').val(r.name || '');
$('#res-slug').val(r.slug || '');
$('#res-kind').val(r.kind || 'host');
$('#res-description').val(r.description || '');
$('#res-ip').val(m.ip || '');
$('#res-address').val(m.address || '');
$('#res-subtype').val(m.subType || '');
$('#res-mac').val(m.macAddress || '');
$('#res-port').val(m.port || '');
$('#res-external-port').val(m.externalPort || '');
$('#res-icon').val(m.icon || '');
$('#res-tagline').val(m.tagline || '');
updateIconPreview();
toggleFormFields();
loadLdapGroups();
}
// --- THETA AGENT INSTALL MODAL & WIZARD ---
// Agent tokens are no longer generated here. The browser minting a token the
// server had never heard of is exactly what made /api/agent/ws unauthenticated:
// there was nothing to validate against. Tokens now come from
// POST /api/agent/enroll (see enrollAgent).
function updateAgentCommands() {
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
const quickToken = $('#agent-quick-token').val() || '';
// public_key must reach the host: without it the agent refuses every
// high-risk command. It was never emitted before, which is why signed
// commands only ever "worked" while verification was being skipped.
// Join-key command. Only a key we just minted can appear here -- the list
// endpoint deliberately never returns key values.
const joinUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
const selectedKeyId = $('#agent-join-key-select').val();
let joinCmd;
if (mintedJoinKey) {
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
} else if (selectedKeyId) {
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
} else {
joinCmd = '# Create a join key above, or select one you already have the value for.';
}
$('#agent-join-command').text(joinCmd);
const pubKey = (pendingEnrollment && pendingEnrollment.publicKey) || '';
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`
+ (pubKey ? ` --public-key "${pubKey}"` : '');
$('#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}"`,
`public_key: "${pubKey}"`,
`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');
});
}
// Enrollment state for the open install modal. The token exists only here,
// in memory, between the enroll call and the operator copying it: the server
// stores a hash and cannot show it again.
var pendingEnrollment = null;
function openAgentInstallModal() {
const currentOrigin = window.location.origin;
pendingEnrollment = null;
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-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="agent-mode-join-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-join" type="button" role="tab">
<i class="fa-solid fa-key me-1"></i> Join key <span class="badge bg-success ms-1">easiest</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="agent-mode-pre-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-pre" type="button" role="tab">
<i class="fa-solid fa-id-badge me-1"></i> Pre-register this host
</button>
</li>
</ul>
<div class="tab-content mb-3">
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
<div class="card border-success">
<div class="card-header py-2 fw-bold small bg-success-subtle">
<i class="fa-solid fa-key me-1"></i> Install with a join key
</div>
<div class="card-body py-3">
<p class="small text-muted mb-3">
Run this on any host and it enrolls itself. The SSO issues that host its own
token and public key on first connect, and the agent writes both into its
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
many hosts as you like; each still gets its own revocable identity.
</p>
<div class="d-flex gap-2 align-items-end mb-3">
<div class="flex-grow-1">
<label class="form-label small fw-bold mb-1">Existing join keys</label>
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
</div>
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
<i class="fa-solid fa-plus me-1"></i> New join key
</button>
</div>
<div id="agent-join-key-result" style="display:none"></div>
<label class="form-label small fw-bold mb-1">Run on the target host (as root):</label>
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-join-command" style="user-select: all;"></pre>
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-success" id="btn-copy-join" onclick="copyAgentCommand('agent-join-command', 'btn-copy-join')">
<i class="fa-solid fa-copy me-1"></i> Copy install command
</button>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header py-2 fw-bold small">
<i class="fa-solid fa-list-check me-1"></i> Manage join keys
</div>
<div class="card-body py-2">
<table class="table table-sm table-hover mb-0 small" id="agent-join-key-table">
<thead>
<tr>
<th>Label</th>
<th>Prefix</th>
<th>Created</th>
<th>Hosts joined</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="agent-join-key-tbody"></tbody>
</table>
<div id="agent-join-key-hosts" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
<div class="card border-primary mb-3" id="agent-enroll-card">
<div class="card-header py-2 fw-bold small bg-primary-subtle">
<i class="fa-solid fa-id-badge me-1"></i> 1. Enroll this host
</div>
<div class="card-body py-3">
<p class="small text-muted mb-3">
Use this when you want the agent bound to a specific Directory host from the start.
The SSO issues the token here and you copy it onto the machine yourself.
</p>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-bold mb-1">Agent name</label>
<input type="text" id="agent-enroll-name" class="form-control form-control-sm" placeholder="e.g. web01">
</div>
<div class="col-md-5">
<label class="form-label small fw-bold mb-1">Bind to host resource</label>
<select id="agent-enroll-resource" class="form-select form-select-sm"></select>
<div class="form-text small">Links the agent to a Directory host, so its status and metrics attach to that resource.</div>
</div>
<div class="col-md-3">
<button class="btn btn-sm btn-primary w-100" id="agent-enroll-btn" onclick="enrollAgent()">
<i class="fa-solid fa-key me-1"></i> Enroll &amp; issue token
</button>
</div>
</div>
<div id="agent-enroll-result" class="mt-3" style="display:none"></div>
</div>
</div>
<div id="agent-install-steps" style="display:none">
<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">Issued Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
</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">Issued Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
</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>
</div>
</div><!-- /pre-register pane -->
</div><!-- /tab-content -->
`;
app.modal.open({
title: 'Install Theta Agent',
bodyHtml: bodyHtml,
size: 'lg'
});
loadAgentJoinKeys();
// Only hosts can carry an agent -- the API rejects anything else, so don't
// offer it here.
const $sel = $('#agent-enroll-resource').empty();
$sel.append('<option value="">(not bound — bind later)</option>');
rawResources
.filter(r => r.kind === 'host')
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.forEach(r => {
const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
$sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
});
$('#agent-enroll-resource').on('change', function () {
const r = rawResources.find(x => x.id === this.value);
if (r && !$('#agent-enroll-name').val()) $('#agent-enroll-name').val(r.name || r.slug);
});
updateAgentCommands();
}
// Join keys the operator can reuse. Values are never returned by the list
// endpoint -- only a prefix -- so the dropdown identifies a key without being
// able to rebuild an install command from it. Minting is the only way to see
// a key's value, and only once.
var agentJoinKeys = []; // non-revoked, for the install-command dropdown
var agentJoinKeysAll = []; // every key, for the management table
var mintedJoinKey = null; // in-memory, for the command shown right now
function loadAgentJoinKeys() {
app.api.get('agent/join-keys', function(err, res) {
agentJoinKeysAll = (res && res.joinKeys ? res.joinKeys : []);
agentJoinKeys = agentJoinKeysAll.filter(k => !k.revoked);
const $sel = $('#agent-join-key-select').empty();
if (!agentJoinKeys.length) {
$sel.append('<option value="">No join keys yet — create one</option>');
} else {
$sel.append('<option value="">Select a key…</option>');
agentJoinKeys.forEach(k => {
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
});
}
const $tbody = $('#agent-join-key-tbody').empty();
$('#agent-join-key-hosts').hide().empty();
if (!agentJoinKeysAll.length) {
$tbody.append('<tr><td colspan="6" class="text-muted">No join keys yet.</td></tr>');
} else {
agentJoinKeysAll.forEach(k => {
const created = k.created_on ? new Date(k.created_on * 1000).toLocaleDateString() : '—';
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : '0 hosts';
const status = k.revoked
? '<span class="badge bg-secondary">Revoked</span>'
: '<span class="badge bg-success">Active</span>';
const revokeBtn = k.revoked ? '' :
`<button class="btn btn-outline-warning btn-sm" title="Revoke -- stops it enrolling new hosts; already-joined hosts are unaffected" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'revoke')"><i class="fa-solid fa-ban"></i></button>`;
const $row = $('<tr>').attr('data-join-key-row', k.id).append(
$('<td>').text(k.label),
$('<td>').append($('<code>').text(k.keyPrefix + '…')),
$('<td>').text(created),
$('<td>').append($('<a href="#">').text(used).on('click', function(e) { e.preventDefault(); viewAgentJoinKeyHosts(k.id, k.label); })),
$('<td>').html(status),
$('<td class="text-end agent-join-key-actions">').html(
'<div class="btn-group btn-group-sm">' + revokeBtn +
`<button class="btn btn-outline-danger btn-sm" title="Delete the key record itself; already-joined hosts keep working" onclick="confirmAgentJoinKeyAction(this, '${k.id}', 'delete')"><i class="fa-solid fa-trash"></i></button>` +
'</div>'
)
);
$tbody.append($row);
});
}
updateAgentCommands();
});
}
async function viewAgentJoinKeyHosts(id, label) {
const $out = $('#agent-join-key-hosts').show().html('<i class="fa-solid fa-spinner fa-spin"></i> Loading…');
try {
const res = await app.api.get(`agent/join-keys/${id}/agents`);
const body = (res && (res.results || res)) || {};
const agents = body.agents || [];
if (!agents.length) {
$out.html(`<div class="alert alert-secondary py-2 small mb-0">No hosts have joined with <strong>${esc(label)}</strong> yet.</div>`);
return;
}
const rows = agents.map(a => {
const dot = a.isOnline ? 'text-success' : 'text-muted';
const seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
return `<tr><td><i class="fa-solid fa-circle ${dot}" style="font-size:8px"></i> ${esc(a.name)}</td><td>${esc(a.enrolled_on ? new Date(a.enrolled_on * 1000).toLocaleDateString() : '—')}</td><td>${esc(seen)}</td></tr>`;
}).join('');
$out.html(
`<div class="small fw-bold mb-1">Hosts joined with ${esc(label)}:</div>` +
'<table class="table table-sm mb-0"><thead><tr><th>Host</th><th>Joined</th><th>Last seen</th></tr></thead><tbody>' + rows + '</tbody></table>'
);
} catch (err) {
$out.html('<div class="alert alert-danger py-2 small mb-0">Could not load hosts: ' + esc(err.message || err) + '</div>');
}
}
// Inline, row-scoped confirm -- swaps the row's action buttons for
// "Revoke/Delete this key? Yes/No" in place. Deliberately not
// app.messages.confirm(): that renders into a single shared .actionMessage
// banner, so a second click before the first resolves leaves a dangling
// `$('body').one('click', ...)` handler from the first call and the banner
// can end up out of sync with which row it's actually confirming for.
// Scoping state to the row itself sidesteps that entirely.
function confirmAgentJoinKeyAction(btn, id, action) {
const isDelete = action === 'delete';
const label = isDelete ? 'Delete' : 'Revoke';
const cls = isDelete ? 'btn-danger' : 'btn-warning';
$(btn).closest('td').html(
`<span class="small me-1">${label}?</span>` +
`<button class="btn ${cls} btn-sm me-1" onclick="reallyDoAgentJoinKeyAction('${id}', '${action}')">Yes</button>` +
`<button class="btn btn-outline-secondary btn-sm" onclick="loadAgentJoinKeys()">No</button>`
);
}
async function reallyDoAgentJoinKeyAction(id, action) {
try {
if (action === 'delete') {
await app.api.delete(`agent/join-keys/${id}`);
app.messages.toast('Join key deleted.', 'success');
} else {
await app.api.post(`agent/join-keys/${id}/revoke`, {});
app.messages.toast('Join key revoked.', 'success');
}
} catch (err) {
app.messages.toast(`Could not ${action}: ` + (err.message || err), 'danger');
}
loadAgentJoinKeys();
}
async function mintAgentJoinKey() {
try {
const res = await app.api.post('agent/join-keys', { label: 'ui' });
const body = (res && (res.results || res)) || {};
if (!body.key) throw new Error(body.message || 'no key returned');
mintedJoinKey = body.key;
$('#agent-join-key-result').show().html(
'<div class="alert alert-success py-2 small mb-3">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
+ '</div>'
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
+ '<div class="input-group input-group-sm mb-3">'
+ '<input type="text" class="form-control font-monospace" readonly value="' + esc(body.key) + '">'
+ '<button class="btn btn-outline-secondary" type="button" id="btn-copy-jk" onclick="copyAgentCommand(\'agent-jk-copy\', \'btn-copy-jk\')"><i class="fa-solid fa-copy"></i></button>'
+ '</div>'
+ '<span id="agent-jk-copy" class="d-none">' + esc(body.key) + '</span>'
);
loadAgentJoinKeys();
updateAgentCommands();
} catch (err) {
app.messages.toast('Could not create a join key: ' + (err.message || err), 'danger');
}
}
// Mint the token server-side, then reveal the install steps built from it.
async function enrollAgent() {
const name = ($('#agent-enroll-name').val() || '').trim();
const resourceId = $('#agent-enroll-resource').val() || null;
if (!name) {
app.messages.toast('Give the agent a name first.', 'warning');
return;
}
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
try {
const res = await app.api.post('agent/enroll', { name, resourceId });
const body = res && (res.results || res);
if (!body || !body.token) throw new Error((body && body.message) || 'enrollment failed');
pendingEnrollment = body;
$('#agent-quick-token').val(body.token);
$('#agent-custom-token').val(body.token);
// The signing key is what makes reboot/arbitrary_bash possible. If the
// server could not load one, say so here rather than letting the operator
// discover it the first time a command is silently refused.
const keyWarn = body.signingAvailable === false
? '<div class="alert alert-warning py-2 small mb-2"><i class="fa-solid fa-triangle-exclamation me-1"></i>'
+ 'The SSO has no agent signing key, so high-risk commands (reboot, configure_ldap, arbitrary_bash) '
+ 'will be refused. Re-run <code>./setup.sh</code> so OpenBao grants <code>secret/agent/*</code>.</div>'
: '';
$('#agent-enroll-result').show().html(
keyWarn +
'<div class="alert alert-success py-2 small mb-2">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
+ 'This token is shown <strong>once</strong> — only its hash is stored. '
+ 'If you lose it, rotate the agent to issue a new one.</div>'
+ '<label class="form-label small fw-bold mb-1">Agent token</label>'
+ '<div class="input-group input-group-sm mb-2">'
+ '<input type="text" class="form-control font-monospace" id="agent-issued-token" readonly value="' + esc(body.token) + '">'
+ '<button class="btn btn-outline-secondary" type="button" onclick="copyAgentCommand(\'agent-issued-token-copy\', \'btn-copy-token\')" id="btn-copy-token"><i class="fa-solid fa-copy"></i></button>'
+ '</div>'
+ '<span id="agent-issued-token-copy" class="d-none">' + esc(body.token) + '</span>'
);
$('#agent-enroll-card').removeClass('border-primary').addClass('border-success');
$btn.html('<i class="fa-solid fa-check me-1"></i> Enrolled');
$('#agent-install-steps').show();
updateAgentCommands();
refreshAgents();
} catch (err) {
$btn.prop('disabled', false).html('<i class="fa-solid fa-key me-1"></i> Enroll &amp; issue token');
app.messages.toast('Enrollment failed: ' + (err.message || err), 'danger');
}
}
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-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>
</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 * * * *';
}
// `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 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>' : '');
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
var isChecked = values[f.key] === true || values[f.key] === 'true' || values[f.key] === 1 || values[f.key] === '1' || (values[f.key] === undefined && f.default !== false);
html += '<div class="mb-3 form-check">' +
'<input type="checkbox" class="form-check-input" id="' + prefix + f.key + '"' + (isChecked ? ' checked' : '') + '>' +
'<label class="form-check-label fw-bold" for="' + prefix + f.key + '">' + label + '</label>' +
'</div>';
} else if (f.type === 'site_select' || f.key === 'location') {
var selectedVal = String(values[f.key] != null ? values[f.key] : (f.default || '')).trim();
var sites = rawResources.filter(r => r.kind === 'site');
var siteOpts = '<option value="">(Default Site)</option>';
sites.forEach(function(s) {
var sel = (s.name === selectedVal || s.slug === selectedVal || (!selectedVal && s.slug === 'site-default')) ? ' selected' : '';
siteOpts += '<option value="' + esc(s.name) + '"' + sel + '>' + esc(s.name) + ' (' + esc(s.slug) + ')</option>';
});
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<select class="form-select" id="' + prefix + f.key + '">' + siteOpts + '</select>' +
'</div>';
} else {
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
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"';
html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
}
});
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) {
if (f.type === 'boolean' || f.type === 'checkbox' || el.type === 'checkbox') {
out[f.key] = el.checked;
} else {
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'
});
});
}
// 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();
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 && agentsById[msg.agentId];
if (a) { a.lastTelemetry = msg.payload; a.isOnline = true; renderTable(); }
});
dirAgentSocket.on('agent.discovery', function(msg){
const a = msg && agentsById[msg.agentId];
if (a) { a.lastDiscovery = msg.payload; a.isOnline = true; renderTable(); }
});
} catch (e) { /* socket is optional; periodic refresh still runs */ }
});
</script>
<%- include('bottom') %>