dc3d760d2b
Closes the gap where all of this session's new server-side capability (live replication, coordinated promotion, identical signing keys) had no UI at all -- an operator using the Master Site modal had no way to know any of it existed or was working. - Master Site modal: new "Live Replication" row (spoke) shows whether this join actually registered for live updates or is stuck on a one-time snapshot; new "Registered Spokes" row (master) shows how many spokes are receiving live pushes. - Join form: new "this site's own reachable URL" field, prefilled from window.location.origin, wired to the selfUrl the join API already supported but the UI never sent -- a UI-driven join previously NEVER registered for live replication, only the setup.sh bootstrap path did. The success toast now reports whether live replication actually activated, not just "joined". - Promote button: success toast now surfaces the handoff result (old master demoted / unreachable / no previous master), so the operator sees immediately whether the coordinated demotion actually happened. - GET /api/site/config no longer returns masterJoinKey or replicationPushToken in the response -- found while wiring this up: live credentials were being sent straight to the browser for every admin session. Replaced with boolean derivatives (hasMasterJoinKey, liveReplication). - GET /api/directory-admin/site-status gained liveReplication (spoke) and registeredSpokesCount (master) so the modal has something to render. Verified by actually driving it in a real browser against a live container (not just code review): logged in, opened the modal, saw the new rows, minted a real join key end-to-end, no console errors. docs/site-join.md rewritten to cover live replication, signing-key sync, coordinated promotion/demote, and the new endpoints -- it previously only described the v2.2.0-v2.3.0 one-time-snapshot behavior.
3521 lines
170 KiB
Plaintext
3521 lines
170 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 class="d-flex align-items-center gap-2">
|
|
<span><i class="fa-solid fa-server"></i> Directory Management</span>
|
|
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
|
|
<span id="site-node-badge" class="badge bg-dark text-warning border border-warning shadow-sm" style="cursor: pointer;" onclick="openSiteStatusModal()" title="Click to view Multi-Site & Gateway status">
|
|
<i class="fa-solid fa-crown me-1 text-warning"></i> Master Site
|
|
</span>
|
|
</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-1" onclick="openEditModal('{{id}}'); return false;" title="{{name}}">
|
|
<strong>{{displayName}}</strong>
|
|
</a>
|
|
{{#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}}
|
|
<span class="badge bg-secondary me-1">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
|
{{#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">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="discovery-show-ignored" onchange="renderDiscoveryTable()">
|
|
<label class="form-check-label small" for="discovery-show-ignored">Show ignored</label>
|
|
</div>
|
|
<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 font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label>
|
|
<select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
|
|
<option value="">-- Custom / Manual --</option>
|
|
<option value="docker">📦 Docker Engine / Container</option>
|
|
<option value="systemd">⚙️ Linux Systemd Service</option>
|
|
<option value="postgresql">🗄️ PostgreSQL Database</option>
|
|
<option value="redis">🔴 Redis In-Memory Cache</option>
|
|
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
|
|
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
|
|
<option value="unifi">📶 UniFi Network Controller</option>
|
|
<option value="k8s">☸️ Kubernetes Cluster Node</option>
|
|
</select>
|
|
<label class="form-label">Sub Type</label>
|
|
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
|
|
</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/<slug>/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() {
|
|
directoryUserCache = null;
|
|
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 != null && cpuDet.cores > 0) ? cpuDet.cores : (d.cpu_details && d.cpu_details.cores ? d.cpu_details.cores : 'N/A');
|
|
const cpuThreads = (cpuDet.threads != null && cpuDet.threads > 0) ? cpuDet.threads : (d.cpu_details && d.cpu_details.threads ? d.cpu_details.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 align-middle"><thead><tr><th>Device</th><th>Mount</th><th>Type</th><th>FS</th><th>Usage</th><th>Used / Total</th></tr></thead><tbody>` +
|
|
disks.map(dk => `<tr>
|
|
<td><code class="text-dark fw-bold">${esc(dk.device || dk.mountpoint)}</code></td>
|
|
<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 style="min-width: 120px;">
|
|
<div class="d-flex align-items-center gap-1">
|
|
<span class="small font-monospace me-1">${fmtNum(dk.usage_percent)}%</span>
|
|
<div class="progress flex-grow-1" style="height:6px"><div class="progress-bar ${dk.usage_percent > 85 ? 'bg-danger' : 'bg-primary'}" style="width:${Math.max(0, Math.min(100, dk.usage_percent))}%"></div></div>
|
|
</div>
|
|
</td>
|
|
<td>${fmtSize(dk.used_bytes || (dk.total_bytes * (dk.usage_percent || 0) / 100))} / ${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 hd = t.host_details || d.host_details || agent.hostDetails || {};
|
|
let hostDetailsHtml = '';
|
|
if (hd.os || hd.kernel || hd.hardware_model || hd.static_hostname) {
|
|
hostDetailsHtml = `
|
|
<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-server me-1"></i> System & Hardware Details</h6>
|
|
</div>
|
|
<div class="card-body p-3 small">
|
|
<div class="row g-2">
|
|
<div class="col-md-6"><strong>Static Hostname:</strong> ${esc(hd.static_hostname || d.hostname || 'N/A')}</div>
|
|
<div class="col-md-6"><strong>Operating System:</strong> ${esc(hd.os || d.os || 'N/A')}</div>
|
|
<div class="col-md-6"><strong>Kernel / Arch:</strong> ${esc(hd.kernel || d.kernel || 'N/A')} (${esc(hd.arch || 'x86_64')})</div>
|
|
<div class="col-md-6"><strong>Hardware Model:</strong> ${esc(hd.hardware_vendor || '')} ${esc(hd.hardware_model || 'N/A')}</div>
|
|
<div class="col-md-6"><strong>Chassis:</strong> ${esc(hd.chassis || 'N/A')}</div>
|
|
<div class="col-md-6"><strong>Firmware:</strong> ${esc(hd.firmware_version || 'N/A')} ${hd.firmware_date ? '(' + esc(hd.firmware_date) + ')' : ''}</div>
|
|
<div class="col-md-6"><strong>Machine ID:</strong> <code class="text-muted">${esc(hd.machine_id || 'N/A')}</code></div>
|
|
<div class="col-md-6"><strong>Boot ID:</strong> <code class="text-muted">${esc(hd.boot_id || 'N/A')}</code></div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
const agentVer = d.version || t.version || agent.version || 'v1.8.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>
|
|
|
|
${hostDetailsHtml} </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(' ') +
|
|
'"><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 => {
|
|
const hasSec = !!(r.hasSecret || r.metadata?.hasSecret || (r.secretKeys && r.secretKeys.length > 0) || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
|
|
if (secretsOnly && !hasSec) {
|
|
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;
|
|
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '…' : (n.name || '');
|
|
// A leaf gets a spacer of the same width, so names stay aligned down
|
|
// 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
|
}
|
|
|
|
function applySubtypeTemplate() {
|
|
const tmpl = $('#res-subtype-template').val();
|
|
if (!tmpl) return;
|
|
$('#res-subtype').val(tmpl);
|
|
const presets = {
|
|
docker: { icon: 'fa-brands fa-docker', tagline: 'Docker Engine Container Workload', port: '2375' },
|
|
systemd: { icon: 'fa-solid fa-gears', tagline: 'Linux Systemd Daemon Service' },
|
|
postgresql: { icon: 'fa-solid fa-database', tagline: 'PostgreSQL Relational Database', port: '5432' },
|
|
redis: { icon: 'fa-solid fa-cubes', tagline: 'Redis In-Memory Key-Value Cache', port: '6379' },
|
|
proxmox: { icon: 'fa-solid fa-server', tagline: 'Proxmox VE Hypervisor Host', port: '8006' },
|
|
wireguard: { icon: 'fa-solid fa-shield-halved', tagline: 'WireGuard VPN Encrypted Tunnel', port: '51820' },
|
|
unifi: { icon: 'fa-solid fa-wifi', tagline: 'UniFi Network Controller', port: '8443' },
|
|
k8s: { icon: 'fa-solid fa-dharmachakra', tagline: 'Kubernetes Cluster Worker Node', port: '6443' }
|
|
};
|
|
if (presets[tmpl]) {
|
|
const p = presets[tmpl];
|
|
if (p.icon) $('#res-icon').val(p.icon);
|
|
if (p.tagline) $('#res-tagline').val(p.tagline);
|
|
if (p.port && !$('#res-port').val()) $('#res-port').val(p.port);
|
|
updateIconPreview();
|
|
}
|
|
}
|
|
|
|
async function openMergeModal(discoveredId, discName) {
|
|
const targets = rawResources.filter(r => r.kind === 'host' || r.kind === 'site' || r.kind === 'service');
|
|
|
|
app.modal.open({
|
|
title: `Merge '${esc(discName)}' into Existing Resource`,
|
|
size: 'md',
|
|
bodyHtml: `
|
|
<div class="p-3">
|
|
<p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
|
|
<div class="mb-3">
|
|
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()">
|
|
<input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}">
|
|
<div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
|
|
${targets.map((r, i) => `
|
|
<button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<strong>${esc(r.name)}</strong>
|
|
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
|
|
</div>
|
|
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
</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>
|
|
`
|
|
}
|
|
});
|
|
}
|
|
|
|
function filterMergeTargets() {
|
|
const q = ($('#merge-search-input').val() || '').toLowerCase();
|
|
$('#merge-targets-list button').each(function() {
|
|
const text = $(this).text().toLowerCase();
|
|
if (text.includes(q)) {
|
|
$(this).removeClass('d-none');
|
|
} else {
|
|
$(this).addClass('d-none');
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectMergeTarget(btn) {
|
|
$('#merge-targets-list button').removeClass('active');
|
|
$(btn).addClass('active');
|
|
$('#merge-target-id').val($(btn).data('id'));
|
|
}
|
|
|
|
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',
|
|
params: {
|
|
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(force) {
|
|
if (!directoryUserCache || force) {
|
|
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 showIgnored = $('#discovery-show-ignored').is(':checked');
|
|
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;
|
|
const isIgnored = r.metadata && (r.metadata.ignored === true || r.metadata.ignored === 'true');
|
|
if (isIgnored && !showIgnored) 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);
|
|
|
|
// Windows: the installer is a GitHub release artifact (built by the
|
|
// theta-agent release workflow — nothing binary lives in this repo). It
|
|
// takes the same values as /SERVER_URL /JOIN_KEY command-line params.
|
|
const WIN_SETUP = 'theta-agent-2.1.0-windows-amd64-setup.exe';
|
|
const WIN_SETUP_URL = `https://github.com/theta42/theta-agent/releases/latest/download/${WIN_SETUP}`;
|
|
function winInstallCmd(url, args) {
|
|
return [
|
|
`$url = '${url}'`,
|
|
`$setup = "$env:TEMP\\ta-setup.exe"`,
|
|
`Invoke-WebRequest "${WIN_SETUP_URL}" -OutFile $setup`,
|
|
`Start-Process -Wait $setup -ArgumentList ${args}`
|
|
].join('\n');
|
|
}
|
|
let joinCmdWin;
|
|
if (mintedJoinKey) {
|
|
joinCmdWin = winInstallCmd(joinUrl, `'/SILENT', "/SERVER_URL=$url", '/JOIN_KEY=${mintedJoinKey}'`);
|
|
} else if (selectedKeyId) {
|
|
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
|
|
joinCmdWin = `# 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.\n` + winInstallCmd(joinUrl, `'/SILENT', "/SERVER_URL=$url", '/JOIN_KEY=<KEY>'`);
|
|
} else {
|
|
joinCmdWin = '# Create a join key above, or select one you already have the value for.';
|
|
}
|
|
$('#agent-join-command-win').text(joinCmdWin);
|
|
|
|
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 quickCmdWin = winInstallCmd(quickUrl,
|
|
`'/SILENT', "/SERVER_URL=$url", '/AUTH_TOKEN=${quickToken}'` + (pubKey ? `, '/PUBLIC_KEY=${pubKey}'` : ''));
|
|
$('#agent-quick-command-win').text(quickCmdWin);
|
|
|
|
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);
|
|
const customCmdWin = winInstallCmd(customUrl, `'/SILENT', "/B64_CONFIG=${b64Config}"`);
|
|
$('#agent-custom-command-win').text(customCmdWin);
|
|
} catch (e) {
|
|
$('#agent-custom-command').text('Error encoding config to Base64');
|
|
$('#agent-custom-command-win').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>
|
|
<label class="form-label small fw-bold mb-1">Windows (PowerShell, as Administrator):</label>
|
|
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-join-command-win" 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>
|
|
<button class="btn btn-sm btn-outline-success ms-2" id="btn-copy-join-win" onclick="copyAgentCommand('agent-join-command-win', 'btn-copy-join-win')">
|
|
<i class="fa-solid fa-copy me-1"></i> Copy (Windows)
|
|
</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 & 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">Theta Directory 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>
|
|
<label class="form-label small fw-bold mb-1 mt-2">Windows (PowerShell, as Administrator):</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-win" 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>
|
|
<button class="btn btn-sm btn-outline-success ms-2" id="btn-copy-quick-win" onclick="copyAgentCommand('agent-quick-command-win', 'btn-copy-quick-win')">
|
|
<i class="fa-solid fa-copy me-1"></i> Copy (Windows)
|
|
</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">Theta Directory 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>
|
|
<label class="form-label small fw-bold mb-1 mt-2">Windows (PowerShell, as Administrator):</label>
|
|
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-custom-command-win" style="user-select: all;"></pre>
|
|
|
|
<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>
|
|
<button class="btn btn-sm btn-outline-success ms-2" id="btn-copy-custom-win" onclick="copyAgentCommand('agent-custom-command-win', 'btn-copy-custom-win')">
|
|
<i class="fa-solid fa-copy me-1"></i> Copy (Windows)
|
|
</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 & 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');
|
|
}
|
|
}
|
|
|
|
// ── Multi-Site & Master Node Controls ─────────────────────────────────────
|
|
async function refreshSiteStatus() {
|
|
try {
|
|
const res = await app.api.get('directory-admin/site-status');
|
|
if (res && res.config) {
|
|
const isMaster = res.config.isMaster;
|
|
const $badge = $('#site-node-badge');
|
|
if (isMaster) {
|
|
$badge.attr('class', 'badge bg-dark text-warning border border-warning shadow-sm')
|
|
.html('<i class="fa-solid fa-crown me-1 text-warning"></i> Master Site');
|
|
} else {
|
|
$badge.attr('class', 'badge bg-secondary text-info border border-info shadow-sm')
|
|
.html('<i class="fa-solid fa-bolt me-1 text-info"></i> Spoke Site (Read-Only)');
|
|
}
|
|
}
|
|
} catch (e) { console.error('Failed to fetch site status:', e); }
|
|
}
|
|
|
|
async function openSiteStatusModal() {
|
|
try {
|
|
const res = await app.api.get('directory-admin/site-status');
|
|
const cfg = res.config || {};
|
|
const isMaster = cfg.isMaster;
|
|
|
|
let html = '<div class="p-2">' +
|
|
'<div class="card mb-3 shadow-sm border-' + (isMaster ? 'warning' : 'info') + '">' +
|
|
'<div class="card-body">' +
|
|
'<h5 class="card-title d-flex align-items-center justify-content-between">' +
|
|
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : '⚡ <strong>Spoke Site Node</strong>') + '</span>' +
|
|
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
|
|
'</h5>' +
|
|
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' +
|
|
'<table class="table table-sm text-start mb-0">' +
|
|
'<tr><th>Local Site Slug:</th><td><code>' + esc(cfg.siteSlug || 'site-default') + '</code></td></tr>' +
|
|
'<tr><th>Master Authority URL:</th><td>' + (cfg.masterUrl ? ('<code>' + esc(cfg.masterUrl) + '</code>') : '<em>(This Node is Master)</em>') + '</td></tr>' +
|
|
'<tr><th>WAN Sync Health:</th><td>' + (res.config.wanConnected === false
|
|
? '<span class="badge bg-danger"><i class="fa-solid fa-xmark me-1"></i> Offline / Disconnected</span>'
|
|
: '<span class="badge bg-success"><i class="fa-solid fa-check me-1"></i> Online / Operational</span>') + '</td></tr>' +
|
|
(!isMaster ? '<tr><th>Live Replication:</th><td>' + (cfg.liveReplication
|
|
? '<span class="badge bg-success"><i class="fa-solid fa-bolt me-1"></i> Live (catalog updates push automatically)</span>'
|
|
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-triangle-exclamation me-1"></i> Snapshot only (re-join to register for live updates)</span>') + '</td></tr>' : '') +
|
|
(isMaster ? '<tr><th>Registered Spokes:</th><td><span class="badge bg-success">' + (cfg.registeredSpokesCount || 0) + ' receiving live updates</span></td></tr>' : '') +
|
|
'<tr><th>Registered Sites:</th><td><span class="badge bg-primary">' + (res.sitesCount || 0) + ' sites</span></td></tr>' +
|
|
'<tr><th>Theta Gateways:</th><td><span class="badge bg-dark">' + (res.gatewaysCount || 0) + ' active gateways</span></td></tr>' +
|
|
'</table>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="alert alert-secondary small mb-3">' +
|
|
'<i class="fa-solid fa-network-wired me-1"></i> <strong>WireGuard Gateway Mesh & NETMAP</strong>: Inter-site routing operates via <code>theta-gateway</code> subnets (<code>10.x.0.0/16</code>) with default NETMAP shadow translations (<code>10.x.168.0/24 → 192.168.1.0/24</code>).' +
|
|
'</div>';
|
|
|
|
// Fresh install (no users/resources yet): offer to JOIN an existing
|
|
// master site instead of seeding a new directory.
|
|
if (isMaster && cfg.canJoin) {
|
|
html += '<div class="card border-primary shadow-sm mt-3">' +
|
|
'<div class="card-body">' +
|
|
'<h6 class="fw-bold"><i class="fa-solid fa-link me-1 text-primary"></i> Join an Existing Site (Spoke)</h6>' +
|
|
'<p class="small text-muted mb-2">This is a fresh install. Join an existing (master) deployment to run as a read-only spoke of its directory — paste the master URL and a site join key minted there.</p>' +
|
|
'<div class="row g-2">' +
|
|
'<div class="col-md-7"><input type="text" id="site-join-url" class="form-control form-control-sm" placeholder="Master Directory URL (e.g. https://sso.master.example.com)"></div>' +
|
|
'<div class="col-md-5"><input type="text" id="site-join-key" class="form-control form-control-sm font-monospace" placeholder="Site join key (stj_...)"></div>' +
|
|
'</div>' +
|
|
'<div class="mt-2">' +
|
|
'<label class="form-label small mb-1">This site\'s own reachable URL <span class="text-muted">(so the master can push live updates here — leave blank to only get a one-time snapshot)</span></label>' +
|
|
'<input type="text" id="site-join-self-url" class="form-control form-control-sm" value="' + esc(window.location.origin) + '">' +
|
|
'</div>' +
|
|
'<button class="btn btn-sm btn-primary mt-2" onclick="joinCurrentSiteToMaster()"><i class="fa-solid fa-link me-1"></i> Join Site</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// A master mints the site join keys spokes present when joining.
|
|
if (isMaster) {
|
|
html += '<div class="card mt-3">' +
|
|
'<div class="card-header py-2 fw-bold small"><i class="fa-solid fa-key me-1"></i> Site Join Keys <span class="text-muted">(for spokes to adopt this directory)</span></div>' +
|
|
'<div class="card-body py-2">' +
|
|
'<div class="d-flex gap-2 align-items-end mb-2">' +
|
|
'<div class="flex-grow-1"><input type="text" id="site-join-key-label" class="form-control form-control-sm" placeholder="label (e.g. staten-island)"></div>' +
|
|
'<button class="btn btn-sm btn-success" onclick="mintSiteJoinKey()"><i class="fa-solid fa-plus me-1"></i> Mint key</button>' +
|
|
'</div>' +
|
|
'<div id="site-join-key-result" class="mb-2"></div>' +
|
|
'<table class="table table-sm table-hover mb-0 small"><thead><tr><th>Label</th><th>Prefix</th><th>Used</th><th>Status</th><th class="text-end"></th></tr></thead><tbody id="site-join-key-tbody"></tbody></table>' +
|
|
'</div>' +
|
|
'</div>';
|
|
loadSiteJoinKeys();
|
|
}
|
|
|
|
if (!isMaster) {
|
|
html += '<div class="card border-danger shadow-sm mt-3">' +
|
|
'<div class="card-body text-center">' +
|
|
'<h6 class="text-danger fw-bold"><i class="fa-solid fa-triangle-exclamation me-1"></i> Promote Node to Master Authority</h6>' +
|
|
'<p class="small text-muted mb-2">Requires explicit <code>god_admin</code> authorization. Spoke nodes remain in Spoke mode automatically during WAN outages to prevent split-brain errors.</p>' +
|
|
'<button class="btn btn-sm btn-danger shadow-sm" onclick="promoteCurrentSiteToMaster()">' +
|
|
'<i class="fa-solid fa-crown me-1"></i> Promote This Node to Master' +
|
|
'</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
app.modal.open({ title: 'Multi-Site & Network Gateway Status', bodyHtml: html, size: 'lg' });
|
|
} catch (e) {
|
|
app.messages.toast('Error fetching site status: ' + e.message, 'danger');
|
|
}
|
|
}
|
|
|
|
async function promoteCurrentSiteToMaster() {
|
|
const confirmed = await app.messages.confirm(
|
|
'Are you sure you want to promote this site node to Master? This grants single write authority for directory catalog changes.',
|
|
app.modal.body(), 'danger'
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const res = await app.api.post('directory-admin/site-promote', { selfUrl: window.location.origin });
|
|
const handoffOk = res.handoff === 'previous master demoted' || /no previous master/.test(res.handoff || '');
|
|
app.messages.toast((res.message || 'Node promoted to Master Site') + ' — ' + (res.handoff || ''), handoffOk ? 'success' : 'warning');
|
|
app.modal.close();
|
|
refreshSiteStatus();
|
|
} catch (e) {
|
|
app.messages.action('Promotion failed: ' + (e.message || e), app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
// ── Site join (spoke adopts a master directory) ───────────────────────────
|
|
async function joinCurrentSiteToMaster() {
|
|
const masterUrl = ($('#site-join-url').val() || '').trim();
|
|
const joinKey = ($('#site-join-key').val() || '').trim();
|
|
const selfUrl = ($('#site-join-self-url').val() || '').trim();
|
|
if (!masterUrl || !joinKey) {
|
|
return app.messages.toast('Enter the master Directory URL and a site join key', 'warning');
|
|
}
|
|
const confirmed = await app.messages.confirm(
|
|
'Join ' + masterUrl + ' as a read-only spoke? This adopts its directory (users, groups, resources).',
|
|
app.modal.body(), 'warning'
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const res = await app.api.post('site/join', { masterUrl, joinKey, ...(selfUrl ? { selfUrl } : {}) });
|
|
const replicationNote = res.replication && res.replication.live
|
|
? ' Live replication is active.'
|
|
: ' Snapshot only — this site will not receive live updates (' + ((res.replication && res.replication.note) || 'no selfUrl given') + ').';
|
|
app.messages.toast((res.message || 'Joined master site') + replicationNote, res.replication && res.replication.live ? 'success' : 'warning');
|
|
app.modal.close();
|
|
refreshSiteStatus();
|
|
} catch (e) {
|
|
app.messages.action('Join failed: ' + (e.message || e), app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function loadSiteJoinKeys() {
|
|
try {
|
|
const res = await app.api.get('site/join-keys');
|
|
const rows = (res.joinKeys || []).map(k => {
|
|
const status = k.revoked
|
|
? '<span class="badge bg-secondary">revoked</span>'
|
|
: '<span class="badge bg-success">active</span>';
|
|
const revoke = k.revoked
|
|
? ''
|
|
: '<button class="btn btn-sm btn-outline-danger" onclick="revokeSiteJoinKey(\'' + k.id + '\')"><i class="fa-solid fa-ban me-1"></i>Revoke</button>';
|
|
return '<tr><td>' + esc(k.label) + '</td><td><code>' + esc(k.keyPrefix) + '</code></td><td>' + (k.use_count || 0) + '</td><td>' + status + '</td><td class="text-end">' + revoke + '</td></tr>';
|
|
}).join('');
|
|
$('#site-join-key-tbody').html(rows);
|
|
} catch (e) { console.error('load site join keys:', e); }
|
|
}
|
|
|
|
async function mintSiteJoinKey() {
|
|
const label = ($('#site-join-key-label').val() || '').trim() || 'default';
|
|
try {
|
|
const res = await app.api.post('site/join-keys', { label });
|
|
$('#site-join-key-result').html(
|
|
'<div class="alert alert-warning small p-2 mb-0">Key created (shown once, copy it now): <code class="user-select-all">' + res.key + '</code>' +
|
|
'<button class="btn btn-sm btn-outline-secondary ms-2" onclick="copySiteJoinKey(this)"><i class="fa-solid fa-copy me-1"></i>Copy</button></div>'
|
|
);
|
|
loadSiteJoinKeys();
|
|
} catch (e) { app.messages.toast('Mint failed: ' + (e.message || e), 'danger'); }
|
|
}
|
|
|
|
function copySiteJoinKey(btn) {
|
|
const code = $(btn).closest('div').find('code').text();
|
|
navigator.clipboard.writeText(code).then(() => {
|
|
$(btn).html('<i class="fa-solid fa-check me-1"></i>Copied!');
|
|
});
|
|
}
|
|
|
|
async function revokeSiteJoinKey(id) {
|
|
try {
|
|
await app.api.post('site/join-keys/' + id + '/revoke', {});
|
|
loadSiteJoinKeys();
|
|
} catch (e) { app.messages.toast('Revoke failed: ' + (e.message || e), 'danger'); }
|
|
}
|
|
|
|
$(document).ready(function(){
|
|
loadDiscoveryResources();
|
|
loadDiscoveryPlugins();
|
|
refreshSiteStatus();
|
|
// 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') %>
|