Compare commits

..

4 Commits

Author SHA1 Message Date
wmantly 0c5159c49b Merge pull request #155 from theta42/fix/sso-directory-conf-vault-v1.20.1
fix(sso): Directory, Configuration, Vault Broker & Plugins migration v1.20.1
2026-08-03 14:01:55 -04:00
wmantly 70b76c6ed5 fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m23s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 5s
2026-08-03 13:54:53 -04:00
wmantly 8143ef8ca8 Merge pull request #154 from theta42/feature/v1.20.0-package-version-and-docs
release: v1.20.0 package version & docs update
2026-08-03 02:41:47 -04:00
wmantly 2b8b7a96e0 release: v1.20.0 - bump package.json version and update docs 2026-08-03 02:37:52 -04:00
12 changed files with 522 additions and 351 deletions
+70 -66
View File
@@ -1,88 +1,92 @@
--- ---
layout: default layout: default
title: Discovery Agents title: Theta Agent & Endpoint Management
nav_order: 5 nav_order: 5
--- ---
# Discovery Agents # Theta Agent & Endpoint Management
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs. The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
## Writing a Custom Agent ---
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`. ## Core Functionality
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`. ### 1. Host Discovery & Inventory
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
### Agent Skeleton If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
```javascript ### 2. Real-Time Telemetry Streaming
// nodejs/agents/discovery/my_custom_agent.js Every 30 seconds, the agent streams real-time performance metrics:
module.exports = { - **CPU Load**: System-wide CPU utilization percentage.
discover: async (config) => { - **Memory Utilization**: RAM usage percentage and available memory.
const { url, apiKey } = config; // Provided by your configuration - **Disk Utilization**: Root filesystem usage percentage.
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
const resources = []; ---
const edges = [];
// 1. Fetch your data from an API ## Local-First Security & Capability Matrix
// const data = await fetch(...);
// 2. Map data to Resources To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
resources.push({
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
name: 'My Switch',
slug: 'my-switch-01',
metadata: {
make: 'Vendor',
model: 'Model X',
interfaces: [
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
]
}
});
// 3. Map relations to Edges (optional) | Capability | Config Key | Risk Level | Description & Impact |
edges.push({ | :--- | :--- | :--- | :--- |
parentSlug: 'my-switch-01', | **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
childSlug: 'some-connected-client-slug', | **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to' | **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
}); | **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
return { resources, edges }; ---
}
}; ## High-Risk Command Verification (Protocol v1.1.0)
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
2. The payload is signed with the SSO Manager's Ed25519 private key.
3. The Base64 signature is appended to the message payload.
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
---
## Installation & Deployment
### Quick One-Liner Install
Run the following command as `root` on the target Linux host:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
``` ```
## Configuration ### Custom Config Wizard
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`: ```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
```javascript
module.exports = {
// ... existing config ...
discovery: {
agents: {
my_custom_agent: {
enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com',
apiKey: 'secret-key'
},
nmap: {
enabled: true,
cron: '0 * * * *',
targetRange: '192.168.1.0/24'
}
}
}
};
``` ```
## The Reconciliation Engine ---
## Configuration File Example (`/etc/theta42/agent.yml`)
```yaml
# /etc/theta42/agent.yml
server_url: "wss://sso.example.com"
auth_token: "your-unique-host-token"
location: "dc-01-rack-12"
public_key: "MCowBQYDK2VwAyEA..."
capabilities:
telemetry: true
configure_ldap: true
reboot: false
service_control: ["nginx", "docker", "sssd"]
arbitrary_bash: false
```
When your agent returns its graph, the Reconciliation Engine takes over:
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+1
View File
@@ -60,6 +60,7 @@ backend, that's the niche.
run the pieces separately via `app_*` env config. run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites. - **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/). - **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
## Get it ## Get it
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.20.0", "version": "1.20.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.20.0", "version": "1.20.1",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
+1 -1
View File
@@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {}; const meta = resource.metadata || {};
meta.managed = true; meta.managed = true;
await resource.update({ metadata: meta }); await Resource.update(resource.id, { metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] })); res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); } } catch (err) { next(err); }
+1 -8
View File
@@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) {
}); });
router.get('/plugins', function(req, res, next) { router.get('/plugins', function(req, res, next) {
// Plugin instances page — loadable/unloadable, configurable plugin copies res.redirect('/directory');
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
// client gates with app.auth.forceLogin(['app_sso_admin',
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
// the same server-side. Same header-vs-navigation auth model as /conf and
// /vault (auth-token is a client-set header, not a cookie).
const registry = require('../services/plugin_registry');
res.render('plugins', {...values, pluginTypes: registry.types });
}); });
router.get('/vault', function(req, res) { router.get('/vault', function(req, res) {
+24 -14
View File
@@ -12,32 +12,39 @@ class DiscoveryReconciler {
res._originalSlug = res.slug; // Keep track for edge mapping res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null; let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
// Attempt matching by MAC if available (case-insensitive)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
if (macs.length > 0) {
const allRes = await Resource.list(); const allRes = await Resource.list();
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
existing = allRes.find(r => existing = allRes.find(r =>
r.metadata && r.metadata.interfaces && r.metadata && (
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase())) (r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
); );
} }
} }
// Fallback matching by IP if no MAC match (weaker) // 2. Fallback matching by IP address
let ipsToMatch = []; let ipsToMatch = [];
if (res.metadata.interfaces) { if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i); ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
} }
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) { if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim())); res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
} }
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) { if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => { existing = allRes.find(r => {
if (!r.metadata) return false; if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) { if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim()); const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true; if (addrs.some(a => ipsToMatch.includes(a))) return true;
@@ -47,13 +54,16 @@ class DiscoveryReconciler {
}); });
} }
// Fallback matching by Slug or Name // 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) { if (!existing && (res.slug || res.name)) {
const allRes = await Resource.list(); const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => existing = allRes.find(r => {
(res.slug && r.slug === res.slug) || if (res.slug && r.slug === res.slug) return true;
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
); if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
return false;
});
} }
if (existing) { if (existing) {
-1
View File
@@ -44,7 +44,6 @@ module.exports = {
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
{href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*. // Vault requires login - per-user secrets at secret/users/<uid>/*.
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
+2 -1
View File
@@ -156,11 +156,12 @@ async function mintAppToken(name) {
// client's sso auth headers so OpenBao never sees them. // client's sso auth headers so OpenBao never sees them.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const ADMIN_GROUP = 'app_sso_admin'; const ADMIN_GROUP = 'app_sso_admin';
async function isAdmin(user) { async function isAdmin(user) {
try { try {
await permission.byGroup(user, [ADMIN_GROUP]); await permission.byGroup(user, ADMIN_GROUPS);
return true; return true;
} catch (e) { } catch (e) {
return false; return false;
+256 -183
View File
@@ -2,10 +2,14 @@
<script type="text/javascript"> <script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']); app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() { $(document).ready(function() {
loadConf(); loadConf();
loadProxyConf(); loadProxyConf();
loadTos(); loadTos();
loadMessagingPlugins();
}); });
async function loadConf() { async function loadConf() {
@@ -76,7 +80,7 @@
} catch (error) { } catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger'); app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally { } finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration'); btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
} }
} }
@@ -87,13 +91,11 @@
return; return;
} }
const $inputGroup = $('#test-email-to').closest('.input-group'); const btn = $('#btn-test-email');
const btn = $inputGroup.find('button');
const originalHtml = btn.html(); const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try { try {
// First save the SMTP config, then send test email
const payload = { const payload = {
smtp: { smtp: {
host: $('#smtp-host').val(), host: $('#smtp-host').val(),
@@ -104,11 +106,7 @@
secure: $('#smtp-secure').is(':checked') secure: $('#smtp-secure').is(':checked')
} }
}; };
// Save config first
await app.api.post('conf', payload); await app.api.post('conf', payload);
// Then send test email
const result = await app.api.post('conf/test-email', { to }); const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success'); app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val(''); $('#test-email-to').val('');
@@ -126,13 +124,11 @@
return; return;
} }
const $inputGroup = $('#test-sms-to').closest('.input-group'); const btn = $('#btn-test-sms');
const btn = $inputGroup.find('button');
const originalHtml = btn.html(); const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try { try {
// First save the VoIP.ms config, then send test SMS
const payload = { const payload = {
voipms: { voipms: {
username: $('#voipms-username').val(), username: $('#voipms-username').val(),
@@ -140,11 +136,7 @@
password: $('#voipms-password').val() password: $('#voipms-password').val()
} }
}; };
// Save config first
await app.api.post('conf', payload); await app.api.post('conf', payload);
// Then send test SMS
const result = await app.api.post('conf/test-sms', { to }); const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success'); app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val(''); $('#test-sms-to').val('');
@@ -201,22 +193,18 @@
} catch (error) { } catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger'); app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally { } finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Proxy Secrets'); btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
} }
} }
// ── Terms of Service editor ──────────────────────────────────────────
// Moved here from the admin Overview dashboard — it's a configuration
// control, so it belongs on the System Configuration page. The API is
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
// matches this page's gate). app.tos.get/update are the shared frontend
// helpers (@simpleworkjs/frontend).
async function loadTos() { async function loadTos() {
try { try {
const tos = await app.tos.get(); const tos = await app.tos.get();
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content; document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent = document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by; 'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
} catch(e) { } catch(e) {
console.error('Failed to load ToS:', e); console.error('Failed to load ToS:', e);
} }
@@ -248,249 +236,334 @@
loadTos(); loadTos();
}); });
} }
// ── Messaging Plugins ──────────────────────────────────────────────
function loadMessagingPlugins() {
app.api.get('plugins/types', function(err, res) {
if (!err && res && res.results) {
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
}
app.api.get('plugins', function(err, res) {
if (err) return;
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
renderMessagingPlugins();
});
});
}
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-3"><i class="fas fa-plug text-secondary mb-2 fs-3"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
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>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function togglePlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function deletePlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
</script> </script>
<div class="container py-4"> <div class="container-fluid py-4 px-md-4">
<div class="row mb-4"> <!-- Page Header -->
<div class="col d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center mb-4 pb-3 border-bottom">
<div> <div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2> <h3 class="fw-bold mb-1"><i class="fas fa-sliders-h text-primary me-2"></i> System Configuration</h3>
<p class="text-muted mb-0"> <p class="text-muted mb-0 small">
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao.
settings. These are stored securely in OpenBao and take effect immediately.
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
are masked — leave them unchanged to keep the stored value.
</p> </p>
</div> </div>
<div> <div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button> <button class="btn btn-outline-secondary me-2" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button> <button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
<div class="row g-4">
<!-- Sidebar Navigation -->
<div class="col-lg-3 col-md-4">
<div class="card shadow-sm border-0 sticky-top" style="top: var(--sw-content-offset, 70px);">
<div class="list-group list-group-flush rounded-3" id="conf-nav-list" role="tablist">
<a class="list-group-item list-group-item-action active d-flex align-items-center py-3" id="tab-nav-oauth" data-bs-toggle="list" href="#pane-oauth" role="tab">
<i class="fas fa-key text-success me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">OAuth & JWT</div>
<div class="small text-muted">Issuer & Token Lifetimes</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-smtp" data-bs-toggle="list" href="#pane-smtp" role="tab">
<i class="fas fa-envelope text-primary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Email (SMTP)</div>
<div class="small text-muted">Mail Delivery & Testing</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-sms" data-bs-toggle="list" href="#pane-sms" role="tab">
<i class="fas fa-comment-sms text-info me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">SMS & Messaging</div>
<div class="small text-muted">VoIP.ms & Webhook Plugins</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-proxy" data-bs-toggle="list" href="#pane-proxy" role="tab">
<i class="fas fa-shield-alt text-warning me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Proxy Secrets</div>
<div class="small text-muted">OpenBao Integration</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-tos" data-bs-toggle="list" href="#pane-tos" role="tab">
<i class="fas fa-file-contract text-secondary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Terms of Service</div>
<div class="small text-muted">User Agreement & Policy</div>
</div>
</a>
</div> </div>
</div> </div>
</div> </div>
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist"> <!-- Main Content Panes -->
<li class="nav-item" role="presentation"> <div class="col-lg-9 col-md-8">
<button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button> <div class="tab-content" id="conf-tab-content">
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
</li>
</ul>
<div class="tab-content" id="confTabsContent"> <!-- OAuth Pane -->
<!-- SMTP Tab --> <div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Host</label> <label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="smtp-host"> <input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Port</label> <label class="form-label fw-semibold">JWT Secret</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test Email</label>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
</div>
</div>
</div>
</div>
<!-- OAuth Tab -->
<div class="tab-pane fade" id="oauth" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********"> <input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div> <div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div> </div>
<div class="mb-3"> <div class="row">
<label class="form-label">Access Token Lifetime (seconds)</label> <div class="col-md-6 mb-3">
<input type="number" class="form-control" id="oauth-token-access"> <label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
</div> </div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- SMS Tab --> <!-- SMTP Pane -->
<div class="tab-pane fade" id="sms" role="tabpanel"> <div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
</div>
<div class="card-body p-4">
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="587">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div> </div>
<div class="card-body">
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API Username</label> <label class="form-label fw-semibold">From Address</label>
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
</div>
<div class="p-3 bg-light rounded border">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane me-1"></i> Send Test Email
</button>
</div>
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
</div>
</div>
<!-- SMS & Messaging Pane -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
</div>
<div class="card-body p-4">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
<input type="text" class="form-control" id="voipms-username"> <input type="text" class="form-control" id="voipms-username">
</div> </div>
<div class="mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">DID (sender number)</label> <label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567"> <input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div> </div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API Password</label> <label class="form-label fw-semibold">API Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********"> <input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div> </div>
<hr class="my-4">
<div class="mb-3"> <div class="p-3 bg-light rounded border mb-4">
<label class="form-label">Send Test SMS</label> <h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
<div class="input-group"> <div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567"> <input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()"> <button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS <i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button> </button>
</div> </div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div> </div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div id="messaging-plugins-list"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Proxy Secrets Tab --> <!-- Proxy Secrets Pane -->
<div class="tab-pane fade" id="proxy" role="tabpanel"> <div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p> <p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6> <h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Issuer URL</label> <label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com"> <input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div> </div>
<div class="mb-3"> <div class="row">
<label class="form-label">Client ID</label> <div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id"> <input type="text" class="form-control" id="proxy-client-id">
</div> </div>
<div class="mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Client Secret</label> <label class="form-label fw-semibold">Client Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********"> <input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
</div> </div>
</div> </div>
</div>
<h6 class="mt-4 mb-2">LDAP Integration</h6> <h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Bind Password</label> <label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********"> <input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Password for the Proxy's LDAP service account.</div>
</div> </div>
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button> <button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div> </div>
</div> </div>
</div> </div>
<!-- ToS Tab --> <!-- Terms of Service Pane -->
<div class="tab-pane fade" id="tos" role="tabpanel"> <div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center"> <div class="card-header bg-white pt-4 pb-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5> <h5 class="mb-0 fw-bold"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<small class="text-muted" id="tos-meta"></small> <span class="small text-muted" id="tos-meta"></span>
</div> </div>
<div class="card-body"> <div class="card-body p-4">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label> <label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control" id="tos-content" rows="8"></textarea> <textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance"> <input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label> <label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
</div> </div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button> <button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-2"></div> <div id="tos-result" style="display:none" class="mt-3"></div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
+95 -5
View File
@@ -13,7 +13,12 @@
</li> </li>
<li class="nav-item" role="presentation"> <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"> <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> Discovery <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> </button>
</li> </li>
</ul> </ul>
@@ -187,6 +192,20 @@
</div> </div>
</div> </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>
<button class="btn btn-sm btn-outline-primary" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1188,6 +1207,7 @@
allEdges.push(res.results); allEdges.push(res.results);
refreshEdgesUI(resourceId); refreshEdgesUI(resourceId);
$('#new-edge-target').val(''); $('#new-edge-target').val('');
await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger'); app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1199,6 +1219,7 @@
await app.api.delete('directory-admin/edges/' + id); await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id); allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val()); refreshEdgesUI($('#res-id').val());
await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1493,7 +1514,7 @@
`; `;
app.modal.open({ app.modal.open({
title: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent', title: 'Install Theta Agent',
bodyHtml: bodyHtml, bodyHtml: bodyHtml,
size: 'lg' size: 'lg'
}); });
@@ -1501,12 +1522,81 @@
updateAgentCommands(); updateAgentCommands();
} }
// Plugin scheduling moved to the dedicated /plugins page (the Agents & var discoveryPlugins = [];
// Scheduler tab here was its old home). Discovery inventory + the discovery
// results table remain on this page. 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';
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>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<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);
});
}
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');
}
}
async function deleteDiscoveryPlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Discovery plugin deleted', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
$(document).ready(function(){ $(document).ready(function(){
loadDiscoveryResources(); loadDiscoveryResources();
loadDiscoveryPlugins();
}); });
</script> </script>