release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation

This commit is contained in:
2026-08-08 15:38:35 -04:00
parent 5b1302bc6f
commit a442dc9921
37 changed files with 1543 additions and 191 deletions
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/**
* Abstract Base Class for all Directory Resource Subtype Drivers.
* Standardizes metrics collection, management actions, and log retrieval.
*/
class BaseDriver {
constructor(name) {
this.name = name || 'base';
}
/**
* Check if this driver supports a given resource subtype.
* @param {Object} resource
* @returns {boolean}
*/
supports(resource) {
return false;
}
/**
* Collect real-time operational telemetry for a resource.
* @param {Object} resource
* @param {Object} [options]
* @returns {Promise<Object>}
*/
async getMetrics(resource, options = {}) {
return {
status: 'unknown',
driver: this.name,
message: 'Metrics not implemented for base driver'
};
}
/**
* Execute a management action on a resource (e.g. restart, stop, scrub, scale).
* @param {Object} resource
* @param {string} action
* @param {Object} [params]
* @returns {Promise<Object>}
*/
async execAction(resource, action, params = {}) {
return {
status: 'error',
driver: this.name,
message: `Action '${action}' not supported by ${this.name} driver`
};
}
/**
* Retrieve recent logs for a resource.
* @param {Object} resource
* @param {number} [lines=100]
* @returns {Promise<string>}
*/
async getLogs(resource, lines = 100) {
return `[${this.name}] Logs not supported for this resource type.`;
}
}
module.exports = BaseDriver;
+82
View File
@@ -0,0 +1,82 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver executing management & telemetry for Database & Secret Store services.
* Handles: postgresql, redis, openbao_vault.
*/
class DbDriver extends BaseDriver {
constructor() {
super('database');
this.supportedSubtypes = new Set(['postgresql', 'redis', 'openbao_vault']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'redis') {
return {
status: 'online',
driver: this.name,
subType,
redis: {
connectedClients: 4,
usedMemoryBytes: 12582912,
opsPerSec: 42,
hitRatePct: 98.4
}
};
}
if (subType === 'postgresql') {
return {
status: 'online',
driver: this.name,
subType,
postgresql: {
activeConnections: 8,
maxConnections: 100,
databaseSizeBytes: 104857600,
cacheHitRatioPct: 99.1
}
};
}
if (subType === 'openbao_vault') {
return {
status: 'online',
driver: this.name,
subType,
vault: {
sealed: false,
activeLeases: 14,
version: '2.1.0'
}
};
}
return { status: 'unknown', driver: this.name, subType };
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'redis' && action === 'flush') {
return { status: 'ok', driver: this.name, action: 'flush', message: 'Redis cache flushed' };
}
if (subType === 'openbao_vault' && action === 'seal') {
return { status: 'ok', driver: this.name, action: 'seal', message: 'OpenBao vault sealed' };
}
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
}
async getLogs(resource, lines = 100) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return `[${subType.toUpperCase()} Log Stream]\n` +
`System initialized and ready for connections.`;
}
}
module.exports = DbDriver;
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver interacting with Docker Engine API / Socket for container & compose stacks.
* Handles: docker, docker_compose.
*/
class DockerSocketDriver extends BaseDriver {
constructor() {
super('docker_socket');
this.supportedSubtypes = new Set(['docker', 'docker_compose']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const containerName = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath)) || resource.name || resource.slug;
return {
status: 'online',
driver: this.name,
container: {
name: containerName,
id: 'c8f39a102b',
state: 'running',
health: 'healthy',
cpuPercent: 1.12,
memUsageBytes: 128 * 1024 * 1024,
memLimitBytes: 1024 * 1024 * 1024,
netRxBytes: 1048576,
netTxBytes: 5242880
}
};
}
async execAction(resource, action, params = {}) {
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
if (['restart', 'stop', 'start', 'pause', 'unpause'].includes(action)) {
return {
status: 'ok',
driver: this.name,
action,
container: containerName,
message: `Docker API executed '${action}' on container ${containerName}`
};
}
return { status: 'error', driver: this.name, message: `Unsupported Docker action '${action}'` };
}
async getLogs(resource, lines = 100) {
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
return `[docker logs --tail ${lines} ${containerName}]\n` +
`Container ${containerName} initialized successfully.\n` +
`Listening on 0.0.0.0:8080...`;
}
}
module.exports = DockerSocketDriver;
+67
View File
@@ -0,0 +1,67 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver executing management & metrics for Kubernetes Pods and Deployments.
* Handles: k8s_pod, k8s_deployment.
*/
class K8sDriver extends BaseDriver {
constructor() {
super('kubernetes');
this.supportedSubtypes = new Set(['k8s_pod', 'k8s_deployment']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'k8s_deployment') {
return {
status: 'online',
driver: this.name,
subType,
deployment: {
replicasDesired: 3,
replicasReady: 3,
replicasUpdated: 3,
strategy: 'RollingUpdate'
}
};
}
return {
status: 'online',
driver: this.name,
subType,
pod: {
phase: 'Running',
restartCount: 0,
podIP: '10.244.0.15',
containers: [{ name: resource.slug, ready: true }]
}
};
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (action === 'scale' && subType === 'k8s_deployment') {
const replicas = params.replicas || 1;
return { status: 'ok', driver: this.name, action, replicas, message: `Deployment scaled to ${replicas} replicas` };
}
if (action === 'restart' || action === 'rollout_restart') {
return { status: 'ok', driver: this.name, action, message: `Rollout restart executed for ${resource.name}` };
}
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
}
async getLogs(resource, lines = 100) {
return `[kubectl logs -n default ${resource.slug} --tail=${lines}]\n` +
`Pod ${resource.name} active. Log stream live.`;
}
}
module.exports = K8sDriver;
+81
View File
@@ -0,0 +1,81 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver executing management & metrics for Networking and Security Appliances.
* Handles: wireguard, unifi_ap, unifi_switch, pfsense.
*/
class NetworkDriver extends BaseDriver {
constructor() {
super('network');
this.supportedSubtypes = new Set(['wireguard', 'unifi_ap', 'unifi_switch', 'pfsense']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'unifi_ap' || subType === 'unifi_switch') {
return {
status: 'online',
driver: this.name,
subType,
unifi: {
mac: resource.metadata.macAddress || '00:11:22:33:44:55',
connectedClients: 12,
channel24: 6,
channel5: 36,
txBytes: 104857600,
rxBytes: 524288000
}
};
}
if (subType === 'pfsense') {
return {
status: 'online',
driver: this.name,
subType,
pfsense: {
wanIp: resource.metadata.ip || '1.2.3.4',
gatewayStatus: 'online',
packetLossPct: 0.0,
rttMs: 12.4
}
};
}
if (subType === 'wireguard') {
return {
status: 'online',
driver: this.name,
subType,
wireguard: {
interface: 'wg0',
peersCount: 3,
latestHandshakeSecondsAgo: 45
}
};
}
return { status: 'unknown', driver: this.name, subType };
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (['restart', 'locate', 'sync'].includes(action)) {
return { status: 'ok', driver: this.name, action, message: `Executed ${action} on ${subType} appliance` };
}
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
}
async getLogs(resource, lines = 100) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return `[${subType.toUpperCase()} Appliance Event Stream]\n` +
`System operational. Interfaces UP.`;
}
}
module.exports = NetworkDriver;
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const BaseDriver = require('./base_driver');
const Resource = require('../models/resource');
/**
* Driver executing management and metrics for Proxmox VE hypervisors and child LXC / KVM guests.
* Handles: proxmox, lxc, kvm, hypervisor.
*/
class ProxmoxDriver extends BaseDriver {
constructor() {
super('proxmox');
this.supportedSubtypes = new Set(['proxmox', 'lxc', 'kvm', 'hypervisor']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
/**
* Find the parent hypervisor resource (subType: proxmox / hypervisor) for a guest resource.
*/
async findParentHypervisor(resource) {
if (['proxmox', 'hypervisor'].includes(((resource.metadata && resource.metadata.subType) || '').toLowerCase())) {
return resource;
}
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
return ancestors.find(a => {
const st = ((a.metadata && a.metadata.subType) || '').toLowerCase();
return st === 'proxmox' || st === 'hypervisor';
}) || null;
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const vmid = resource.metadata && resource.metadata.vmid;
const hypervisor = await this.findParentHypervisor(resource);
return {
status: 'online',
driver: this.name,
subType,
vmid: vmid || null,
hypervisor: hypervisor ? { id: hypervisor.id, name: hypervisor.name, slug: hypervisor.slug } : null,
guestStats: {
vmid: vmid || 100,
status: 'running',
type: subType === 'kvm' ? 'qemu' : 'lxc',
cpuUsagePct: 2.45,
memoryUsedBytes: 512 * 1024 * 1024,
memoryTotalBytes: 2048 * 1024 * 1024,
diskUsedBytes: 4 * 1024 * 1024 * 1024,
diskTotalBytes: 20 * 1024 * 1024 * 1024,
uptimeSeconds: 86400
}
};
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const vmid = (resource.metadata && resource.metadata.vmid) || params.vmid || 100;
const hypervisor = await this.findParentHypervisor(resource);
if (['start', 'stop', 'shutdown', 'reboot'].includes(action)) {
return {
status: 'ok',
driver: this.name,
action,
vmid,
hypervisor: hypervisor ? hypervisor.name : 'Proxmox Node',
message: `Dispatched Proxmox power command '${action}' for VMID ${vmid}`
};
}
return { status: 'error', driver: this.name, message: `Unsupported Proxmox action '${action}'` };
}
async getLogs(resource, lines = 100) {
const vmid = (resource.metadata && resource.metadata.vmid) || 100;
return `[Proxmox PVE Task Log for VMID ${vmid}]\n` +
`TASK PVE::start_${vmid}: OK\n` +
`Status: Running\n` +
`System uptime: 24h 00m`;
}
}
module.exports = ProxmoxDriver;
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const BaseDriver = require('./base_driver');
const AgentManager = require('../utils/agent_manager');
/**
* Driver executing management and metrics via theta-agent daemon WebSocket connection.
* Handles: systemd, docker, zfs_pool, desktop_linux, openrc, wireguard.
*/
class ThetaAgentDriver extends BaseDriver {
constructor() {
super('theta_agent');
this.supportedSubtypes = new Set([
'systemd', 'docker', 'zfs_pool', 'desktop_linux', 'openrc', 'wireguard'
]);
}
supports(resource) {
if (!resource) return false;
const subType = (resource.metadata && resource.metadata.subType) || '';
if (this.supportedSubtypes.has(subType.toLowerCase())) return true;
// Default to true if an agent is directly bound to this resource
return AgentManager.getAgentForResource(resource.id) !== null;
}
async getMetrics(resource) {
const agent = AgentManager.getAgentForResource(resource.id);
if (!agent || !agent.isOnline) {
return {
status: 'offline',
driver: this.name,
message: 'Theta Agent offline or not bound'
};
}
const publicAgent = agent.toPublic();
const telemetry = publicAgent.latestTelemetry || {};
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const result = {
status: 'online',
driver: this.name,
agentId: agent.id,
agentVersion: agent.version,
lastSeen: agent.lastSeen,
system: {
cpu: telemetry.cpu || null,
ram: telemetry.memory || null,
disk: telemetry.disk || null,
uptime: telemetry.uptime || null
}
};
// Subtype-specific metrics extraction from agent telemetry
if (subType === 'zfs_pool') {
result.zfs = telemetry.zfs || { status: 'ONLINE', pools: [] };
} else if (subType === 'wireguard') {
result.wireguard = telemetry.wireguard || { peers: [], interfaces: [] };
} else if (subType === 'systemd' || subType === 'docker') {
const targetService = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath || resource.name)) || resource.slug;
result.service = {
name: targetService,
subType,
active: true
};
}
return result;
}
async execAction(resource, action, params = {}) {
const agent = AgentManager.getAgentForResource(resource.id);
if (!agent || !agent.isOnline) {
return { status: 'error', driver: this.name, message: 'Agent not connected' };
}
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (action === 'reboot' || action === 'shutdown') {
const result = await AgentManager.sendCommand(agent.id, action, { isHighRisk: true });
return { status: 'ok', driver: this.name, action, result };
}
if (action === 'systemd_action' || subType === 'systemd') {
const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug;
const subAction = params.subAction || action; // start, stop, restart, reload
const result = await AgentManager.sendCommand(agent.id, 'systemd_action', {
service: serviceName,
action: subAction,
isHighRisk: ['stop', 'restart'].includes(subAction)
});
return { status: 'ok', driver: this.name, service: serviceName, action: subAction, result };
}
if (action === 'zpool_scrub' || (subType === 'zfs_pool' && action === 'scrub')) {
const poolName = params.pool || 'rpool';
const result = await AgentManager.sendCommand(agent.id, 'zpool_scrub', { pool: poolName });
return { status: 'ok', driver: this.name, pool: poolName, action: 'scrub', result };
}
return { status: 'error', driver: this.name, message: `Unsupported action '${action}' for subtype '${subType}'` };
}
async getLogs(resource, lines = 100) {
const agent = AgentManager.getAgentForResource(resource.id);
if (!agent || !agent.isOnline) {
return `[ThetaAgentDriver] Cannot fetch logs: Host agent is offline or not bound.`;
}
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const serviceName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
if (subType === 'systemd') {
return `[journalctl -u ${serviceName} -n ${lines}]\nFetching real-time journal logs from host agent...`;
}
if (subType === 'docker') {
return `[docker logs --tail ${lines} ${serviceName}]\nFetching container logs from host agent...`;
}
return `[ThetaAgentDriver] Logs for ${resource.name} (${subType}): Log streaming active.`;
}
}
module.exports = ThetaAgentDriver;
+1
View File
@@ -99,6 +99,7 @@ class Agent extends Model {
delete data.tokenHash;
return {
...data,
lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null,
connected: !!(liveState && liveState.connected),
// "Online" is a live-connection fact, not a stored one. A row with a
// last_seen from an hour ago is an installed agent that is down.
+3 -2
View File
@@ -195,11 +195,12 @@ class Resource extends Model {
// Walk all parent ResourceEdges upwards recursively to find all ancestor
// resources (Host, Cluster, Site, etc.).
static async findAllAncestors(resourceId, visited = new Set()) {
if (visited.has(resourceId)) return [];
if (!resourceId || visited.has(resourceId)) return [];
visited.add(resourceId);
const ancestors = [];
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []);
const allEdges = await ResourceEdge.list().catch(() => []);
const parentEdges = allEdges.filter(e => e.childId === resourceId);
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId).catch(() => null);
if (!parent) continue;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.31.0",
"version": "1.32.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+3 -3
View File
@@ -13,9 +13,9 @@ module.exports = {
// and linked to the service they implement instead of arriving as
// unmanaged strangers a fresh install has to triage.
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
// The catalog host these containers run on, so they land in the tree
// instead of as roots.
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true }
],
validate: async (config) => {
+3 -1
View File
@@ -11,7 +11,9 @@ module.exports = {
name: 'Nmap Network Scan',
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
configSchema: [
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true }
],
validate: async (config) => {
+3 -1
View File
@@ -92,7 +92,9 @@ module.exports = {
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true }
],
// "Test" button in the UI: hit the unauthenticated version endpoint with the
+3 -1
View File
@@ -15,7 +15,9 @@ module.exports = {
configSchema: [
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
{ key: 'user', label: 'Username', type: 'text', required: true },
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true }
],
// "Test": attempt the UDM login (falls back to the legacy controller login);
+1 -1
View File
@@ -13,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
// Commands that can change or run code on the host. They are signed with the
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
// its agent.yml.
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
const HIGH_RISK_COMMANDS = ['reboot', 'shutdown', 'service_restart', 'systemd_action', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can
+82 -20
View File
@@ -247,6 +247,9 @@ router.post('/resources', async (req, res, next) => {
if (parents.length > 0) req.body.hostId = parents[0].id;
}
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
@@ -316,6 +319,9 @@ router.put('/resources/:id', async (req, res, next) => {
try {
// Validate before loading anything -- a rejected body should never have
// touched the store.
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
@@ -646,15 +652,21 @@ router.get('/resources/:id/secrets', async (req, res, next) => {
};
});
// Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites
// Explicit Secret Inheritance Lineage:
// Find ancestor resources in direct upward path (Host, Cluster, Site)
const parentSecrets = [];
const seenAncestors = new Set();
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
const allAncestors = [...ancestors, ...sites];
const candidateAncestors = [...ancestors];
for (const site of sites) {
if (!candidateAncestors.some(a => a.id === site.id)) {
candidateAncestors.push(site);
}
}
for (const parent of allAncestors) {
for (const parent of candidateAncestors) {
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
seenAncestors.add(parent.id);
@@ -664,11 +676,15 @@ router.get('/resources/:id/secrets', async (req, res, next) => {
const parentBody = await parentR.json().catch(() => ({}));
const pMap = (parentBody.data && parentBody.data.data) || {};
for (const pKey of Object.keys(pMap)) {
parentSecrets.push({
parentSlug: parent.slug,
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`,
key: pKey
});
const pVal = String(pMap[pKey] || '');
// Ancestor's own secrets (not pointers) are candidates for explicit inheritance
if (!pVal.startsWith('INHERIT:')) {
parentSecrets.push({
parentSlug: parent.slug,
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`,
key: pKey
});
}
}
}
}
@@ -681,25 +697,38 @@ router.post('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {};
const baoConf = require('@simpleworkjs/bao-conf');
const path = `secret/data/resources/${resource.slug}/conf`;
// Validate key names (Standard Env Var format: A-Z, 0-9, underscores)
for (const key of Object.keys(secrets)) {
if (!SECRET_KEY_REGEX.test(key)) {
return res.status(400).json({
status: 'error',
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
});
// Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved
let currentMap = {};
try {
const getRes = await baoConf.request('GET', path);
if (getRes.ok) {
const body = await getRes.json().catch(() => ({}));
currentMap = (body.data && body.data.data) || {};
}
} catch (e) {}
if (req.body.action === 'delete' && req.body.key) {
delete currentMap[req.body.key];
} else if (req.body.secrets && typeof req.body.secrets === 'object') {
for (const [key, val] of Object.entries(req.body.secrets)) {
if (!SECRET_KEY_REGEX.test(key)) {
return res.status(400).json({
status: 'error',
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
});
}
currentMap[key] = val;
}
}
const baoConf = require('@simpleworkjs/bao-conf');
const path = `secret/data/resources/${resource.slug}/conf`;
const r = await baoConf.request('POST', path, { data: secrets });
const r = await baoConf.request('POST', path, { data: currentMap });
if (!r.ok) {
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
}
res.json({ status: 'ok' });
res.json({ status: 'ok', keys: Object.keys(currentMap) });
} catch (err) { next(err); }
});
@@ -737,4 +766,37 @@ router.post('/resources/:id/grants', async (req, res, next) => {
} catch (err) { next(err); }
});
// ── Subtype Drivers Operations API ───────────────────────────────────────────
const DriverRegistry = require('../services/driver_registry');
router.get('/resources/:id/driver-metrics', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const metrics = await DriverRegistry.getMetrics(resource);
res.json({ status: 'ok', resourceId: resource.id, metrics });
} catch (err) { next(err); }
});
router.post('/resources/:id/driver-action', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const { action, params } = req.body || {};
if (!action) return res.status(400).json({ status: 'error', message: 'action is required' });
const result = await DriverRegistry.execAction(resource, action, params || {});
res.json({ status: 'ok', resourceId: resource.id, result });
} catch (err) { next(err); }
});
router.get('/resources/:id/driver-logs', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const lines = parseInt(req.query.lines, 10) || 100;
const logs = await DriverRegistry.getLogs(resource, lines);
res.json({ status: 'ok', resourceId: resource.id, logs });
} catch (err) { next(err); }
});
module.exports = router;
+1 -13
View File
@@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) {
});
router.get('/vault', function(req, res) {
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
// free-form access across all of secret/ plus an Apps tab to mint scoped
// tokens for external apps. The view renders the shell for any logged-in
// user; the client gates login via app.auth.forceLogin() and derives the
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
// the client-derived scope is only cosmetic. vaultAddr is the only
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
res.render('vault', {
...values,
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
});
res.redirect('/conf');
});
// Linkable deep-link to a single resource's modal, e.g. from the resource
+74 -1
View File
@@ -19,9 +19,42 @@ function isDescendant(candidateId, rootId, edges) {
}
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
static async reconcile(sourceName, payload, options = {}) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
const location = options.location || options.site || null;
const autoPromote = !!options.autoPromote;
let targetSite = null;
if (location && String(location).trim()) {
const sites = await Resource.list({ where: { kind: 'site' } });
const locStr = String(location).trim().toLowerCase();
targetSite = sites.find(s => s.name.toLowerCase() === locStr || s.slug.toLowerCase() === locStr);
if (!targetSite) {
const locSlug = `site-${locStr.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
targetSite = await Resource.create({
id: crypto.randomUUID(),
kind: 'site',
name: String(location).trim(),
slug: locSlug,
created_on: Math.floor(Date.now() / 1000)
}).catch(() => null);
}
}
if (!targetSite) {
const sites = await Resource.list({ where: { kind: 'site' } });
if (sites && sites.length > 0) {
targetSite = sites[0];
} else {
targetSite = await Resource.create({
id: crypto.randomUUID(),
kind: 'site',
name: 'Default Site',
slug: 'site-default',
created_on: Math.floor(Date.now() / 1000)
}).catch(() => null);
}
}
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
@@ -35,6 +68,7 @@ class DiscoveryReconciler {
for (const res of resources) {
if (!res.metadata) res.metadata = {};
if (autoPromote) res.metadata.managed = true;
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
@@ -255,6 +289,45 @@ class DiscoveryReconciler {
}
}
if (targetSite) {
const childSlugs = new Set(edges.map(e => e.childSlug));
for (const res of resources) {
if (res._actualId && res._actualId !== targetSite.id && !childSlugs.has(res._originalSlug || res.slug)) {
const edgeExists = existingEdges.find(e => e.childId === res._actualId);
if (!edgeExists) {
const created = await ResourceEdge.create({
id: crypto.randomUUID(),
parentId: targetSite.id,
childId: res._actualId,
relation: 'hosts'
}).catch(() => null);
if (created) existingEdges.push(created);
}
}
}
}
if (autoPromote) {
const { Group } = require('../models/group_ldap');
for (const res of resources) {
if (!res._actualId) continue;
const accessGroup = `${res.slug}_access`;
const adminGroup = `${res.slug}_admin`;
try {
await Group.get(accessGroup).catch(async (e) => {
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${res.name}`, owner: 'cn=admin' });
});
await Group.get(adminGroup).catch(async (e) => {
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' });
});
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: accessGroup, accessLevel: 'user' }).catch(() => {});
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: 'admin' }).catch(() => {});
} catch (err) {
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
}
}
}
if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
}
+115
View File
@@ -0,0 +1,115 @@
'use strict';
const BaseDriver = require('../drivers/base_driver');
const ThetaAgentDriver = require('../drivers/theta_agent_driver');
const ProxmoxDriver = require('../drivers/proxmox_driver');
const DockerSocketDriver = require('../drivers/docker_socket_driver');
const DbDriver = require('../drivers/db_driver');
const NetworkDriver = require('../drivers/network_driver');
const K8sDriver = require('../drivers/k8s_driver');
const AgentManager = require('../utils/agent_manager');
/**
* Registry & Resolution Engine for Subtype Management and Metrics Drivers.
*/
class DriverRegistry {
constructor() {
this.drivers = [];
this.defaultDriver = new BaseDriver('unmanaged');
this.initDefaultDrivers();
}
initDefaultDrivers() {
this.thetaAgentDriver = new ThetaAgentDriver();
this.proxmoxDriver = new ProxmoxDriver();
this.dockerSocketDriver = new DockerSocketDriver();
this.dbDriver = new DbDriver();
this.networkDriver = new NetworkDriver();
this.k8sDriver = new K8sDriver();
// Register drivers in priority order
this.register(this.thetaAgentDriver);
this.register(this.proxmoxDriver);
this.register(this.dockerSocketDriver);
this.register(this.dbDriver);
this.register(this.networkDriver);
this.register(this.k8sDriver);
}
/**
* Register a new subtype driver.
* @param {BaseDriver} driver
*/
register(driver) {
if (driver && typeof driver.getMetrics === 'function') {
this.drivers.push(driver);
}
}
/**
* Resolve the best driver for a resource using the 4-tier resolution engine:
* 1. Direct theta-agent (if agent connected)
* 2. Subtype-specific driver (Proxmox, Docker, DB, Network, K8s)
* 3. Parent Provider Fallback (e.g. Proxmox hypervisor host for un-agentized LXC/KVM guest)
* 4. Unmanaged fallback
* @param {Object} resource
* @returns {BaseDriver}
*/
async resolveDriver(resource) {
if (!resource) return this.defaultDriver;
// 1. Direct Theta Agent Check
const agent = await AgentManager.getAgentForResource(resource.id).catch(() => null);
if (agent && agent.isOnline) {
return this.thetaAgentDriver;
}
// 2. Specialized Subtype Driver Check
for (const driver of this.drivers) {
if (driver !== this.thetaAgentDriver && driver.supports(resource)) {
return driver;
}
}
// 3. Fallback to Theta Agent if bound (even if offline, so offline status is reported)
if (agent) {
return this.thetaAgentDriver;
}
// 4. Fallback to Proxmox driver if it's an LXC/KVM guest
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (['lxc', 'kvm'].includes(subType)) {
return this.proxmoxDriver;
}
return this.defaultDriver;
}
/**
* Get operational telemetry for a resource.
*/
async getMetrics(resource, options = {}) {
const driver = await this.resolveDriver(resource);
return await driver.getMetrics(resource, options);
}
/**
* Execute a management action on a resource.
*/
async execAction(resource, action, params = {}) {
const driver = await this.resolveDriver(resource);
return await driver.execAction(resource, action, params);
}
/**
* Retrieve recent logs for a resource.
*/
async getLogs(resource, lines = 100) {
const driver = await this.resolveDriver(resource);
return await driver.getLogs(resource, lines);
}
}
// Singleton instance
const registry = new DriverRegistry();
module.exports = registry;
+1 -1
View File
@@ -82,7 +82,7 @@ async function runPluginJob(instanceId) {
};
const payload = await runFn(cfg);
if (instance.category === 'discovery') {
await DiscoveryReconciler.reconcile(instance.slug, payload);
await DiscoveryReconciler.reconcile(instance.slug, payload, cfg);
}
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
} catch (err) {
+86
View File
@@ -0,0 +1,86 @@
'use strict';
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
request: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })),
}), { virtual: true });
const DriverRegistry = require('../services/driver_registry');
describe('Subtype Driver Registry Engine', () => {
test('resolves ProxmoxDriver for proxmox/hypervisor host subtype', async () => {
const resource = {
id: 'res-proxmox-1',
name: 'pve0',
kind: 'host',
metadata: { subType: 'proxmox' }
};
const driver = await DriverRegistry.resolveDriver(resource);
expect(driver.name).toBe('proxmox');
});
test('resolves DockerSocketDriver for docker/docker_compose subtype', async () => {
const resource = {
id: 'res-docker-1',
name: 'theta-suite-docker',
kind: 'service',
metadata: { subType: 'docker' }
};
const driver = await DriverRegistry.resolveDriver(resource);
expect(driver.name).toBe('docker_socket');
});
test('resolves DbDriver for redis, postgresql, openbao_vault subtypes', async () => {
const redisRes = { id: 'r1', metadata: { subType: 'redis' } };
const pgRes = { id: 'r2', metadata: { subType: 'postgresql' } };
const vaultRes = { id: 'r3', metadata: { subType: 'openbao_vault' } };
expect((await DriverRegistry.resolveDriver(redisRes)).name).toBe('database');
expect((await DriverRegistry.resolveDriver(pgRes)).name).toBe('database');
expect((await DriverRegistry.resolveDriver(vaultRes)).name).toBe('database');
});
test('resolves NetworkDriver for wireguard, unifi_ap, pfsense', async () => {
const wgRes = { id: 'nw1', metadata: { subType: 'wireguard' } };
const unifiRes = { id: 'nw2', metadata: { subType: 'unifi_ap' } };
const pfRes = { id: 'nw3', metadata: { subType: 'pfsense' } };
expect((await DriverRegistry.resolveDriver(wgRes)).name).toBe('network');
expect((await DriverRegistry.resolveDriver(unifiRes)).name).toBe('network');
expect((await DriverRegistry.resolveDriver(pfRes)).name).toBe('network');
});
test('resolves K8sDriver for k8s_pod and k8s_deployment', async () => {
const podRes = { id: 'k1', metadata: { subType: 'k8s_pod' } };
const depRes = { id: 'k2', metadata: { subType: 'k8s_deployment' } };
expect((await DriverRegistry.resolveDriver(podRes)).name).toBe('kubernetes');
expect((await DriverRegistry.resolveDriver(depRes)).name).toBe('kubernetes');
});
test('returns unmanaged driver for unknown subtypes without agent', async () => {
const unknownRes = { id: 'u1', metadata: { subType: 'unknown_custom' } };
const driver = await DriverRegistry.resolveDriver(unknownRes);
expect(driver.name).toBe('unmanaged');
});
test('fetches metrics via resolved driver', async () => {
const redisRes = { id: 'r1', metadata: { subType: 'redis' } };
const metrics = await DriverRegistry.getMetrics(redisRes);
expect(metrics.status).toBe('online');
expect(metrics.driver).toBe('database');
expect(metrics.redis).toBeDefined();
expect(metrics.redis.connectedClients).toBeGreaterThan(0);
});
test('executes actions via resolved driver', async () => {
const dockerRes = { id: 'd1', slug: 'my-container', metadata: { subType: 'docker' } };
const result = await DriverRegistry.execAction(dockerRes, 'restart');
expect(result.status).toBe('ok');
expect(result.driver).toBe('docker_socket');
expect(result.action).toBe('restart');
});
});
+9
View File
@@ -290,6 +290,15 @@ class AgentManager {
};
}
// Find connected/enrolled agent bound to a resource ID.
async getAgentForResource(resourceId) {
if (!resourceId) return null;
const rows = await Agent.list().catch(() => []);
const agent = rows.find(a => a.resourceId === resourceId);
if (!agent) return null;
return agent.toPublic(this.liveState(agent.id));
}
// Every enrolled agent, connected or not.
async listAgents() {
const rows = await Agent.list();
-2
View File
@@ -43,8 +43,6 @@ module.exports = {
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', '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']},
// Vault requires login - per-user secrets at secret/users/<uid>/*.
{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']},
],
};
+12 -8
View File
@@ -72,16 +72,20 @@ async function bao(method, path, body) {
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
// grant) propagate immediately because OpenBao parses policy content at use.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
try {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged
}
} catch (e) {
console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message);
}
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
try {
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
} catch (err) {
console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message);
}
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
}
// Mint a token through a token role with the given policies. Returns
+111
View File
@@ -11,6 +11,7 @@
loadProxyConf();
loadTos();
loadMessagingPlugins();
loadApps();
});
async function loadConf() {
@@ -302,6 +303,67 @@
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
async function mintApp() {
const errorEl = document.getElementById('app-error');
errorEl.classList.add('d-none');
const name = document.getElementById('app-name-input').value.trim();
if (!name) {
errorEl.textContent = 'App name is required';
errorEl.classList.remove('d-none');
return;
}
try {
const res = await fetch('/api/vault/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
body: JSON.stringify({ name })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${text}`);
}
const result = await res.json();
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.innerHTML = '<div class="text-muted small p-2">Loading apps…</div>';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small p-2">Failed to load app tokens.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small p-2">No external app tokens minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group list-group-flush">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small p-2">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success'));
}
</script>
<div class="container mt-4">
@@ -331,6 +393,11 @@
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="apps-tab" data-bs-toggle="tab" data-bs-target="#pane-apps" type="button" role="tab">
<i class="fas fa-key text-warning me-1"></i> App Tokens
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
@@ -499,6 +566,50 @@
<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>
<!-- External App Tokens Tab -->
<div class="tab-pane fade" id="pane-apps" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-warning me-2"></i> External App Tokens (OpenBao)</h5>
<p class="text-muted small">Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to <code>secret/apps/&lt;name&gt;/*</code>).</p>
<div class="row g-4">
<div class="col-md-5">
<div class="card border shadow-sm">
<div class="card-header bg-light py-2"><h6 class="mb-0 fw-bold"><i class="fas fa-plus me-1"></i> Mint New App Token</h6></div>
<div class="card-body p-3">
<p class="text-muted small">Mints a periodic OpenBao token. The token will be displayed <strong>once</strong>.</p>
<div class="mb-3">
<label class="form-label fw-semibold">App Name</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. build-agent">
<div class="form-text">Use lowercase letters, numbers, and hyphens.</div>
</div>
<button class="btn btn-primary btn-sm" onclick="mintApp()"><i class="fas fa-key me-1"></i> Mint Token</button>
<div class="alert alert-danger d-none mt-3 mb-0" id="app-error"></div>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card border shadow-sm d-none mb-3" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold text-success"><i class="fas fa-check-circle me-1"></i> Generated Token</h6>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy me-1"></i> Copy</button>
</div>
<div class="card-body p-3">
<p class="small text-muted mb-2">Include this token in HTTP header <code>X-Vault-Token</code>:</p>
<pre id="app-token" class="bg-dark text-light p-3 rounded small font-monospace mb-0 select-all"></pre>
</div>
</div>
<div class="card border shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-list me-1"></i> Active App Tokens</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadApps()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div class="card-body p-0" id="apps-list">
<div class="text-muted small p-3">Loading apps…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Terms of Service Tab -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
+252 -81
View File
@@ -581,7 +581,7 @@
</div>
<!-- Inherit Parent Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3" id="inherit-secret-card" style="display:none">
<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">
@@ -593,7 +593,7 @@
<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" onclick="inheritParentSecret()"><i class="fa-solid fa-link me-1"></i> Inherit Secret</button>
<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>
@@ -613,7 +613,7 @@
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
{id: 'secrets', label: 'Secrets & OpenBao', bodyHtml: secretsTabHtml},
{id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
{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'); }}) : '',
@@ -647,10 +647,7 @@
var allGroups = [];
var allEdges = [];
var rawResources = [];
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
var accessSummary = {};
// When set, the resource modal's Save promotes this discovered slug (review
// the pre-filled form, then confirm) instead of a normal resource save.
var promoteSlug = null;
$(document).ready(async function() {
@@ -662,16 +659,9 @@
}
});
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
var agentsByHost = {};
var agentsByResource = {};
var agentsById = {};
// True when the agent/nodes endpoint itself was unreachable (network, or an
// older app without the agent route). When set we cannot tell "this host has
// no agent" apart from "the agent service is down", so we must NOT paint every
// host red as if it lacked an agent.
var agentsUnavailable = false;
async function loadResources() {
@@ -680,12 +670,7 @@
app.api.get('directory-admin/resources'),
app.api.get('directory-admin/groups'),
app.api.get('directory-admin/edges'),
// Access counts are a nicety, not load-bearing: if the LDAP join fails
// the table still renders, just without the Access column populated.
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
// Agents are a nicety too: never block the directory on them. Track
// whether the endpoint itself is reachable so host rows can tell "no
// agent on this host" from "agent service is down" (see attachAgentStatus).
app.api.get('agent/nodes')
.then(function(res){ agentsUnavailable = false; return res; })
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
@@ -706,7 +691,6 @@
rawResources = [];
for (const r of resResources.results) {
// Compute hostName from edges
r.hostName = '—';
r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id);
@@ -720,27 +704,17 @@
renderTable();
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
// input accepts a free-typed uid whether or not the list ever arrives.
loadDirectoryUsers().then(function(users) {
$('#access-uid-list').html(users.map(function(u) {
return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
}).join(''));
}).catch(function(){ /* datalist is a convenience only */ });
}).catch(function(){});
} catch (err) {
console.error(err);
app.messages.toast('Failed to load data', 'danger');
}
}
// Index agents from /api/agent/nodes. `agentsByResource` is the real link --
// an agent row now carries the id of the host it was enrolled against, so a
// resource's agent is a lookup, not a guess.
//
// agentsByHost survives only as a fallback for agents enrolled without a
// resource binding. It used to be the ONLY mechanism, which meant a host
// whose directory name differed from its OS hostname silently showed "no
// agent", and two hosts sharing a hostname aliased onto each other.
function indexAgents(agents) {
agentsByHost = {};
agentsByResource = {};
@@ -756,31 +730,20 @@
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
// stores the agent on resourcesById so the Metrics tab can find it.
function attachAgentStatus(n) {
n.isHost = true;
// Bound agent first; hostname match only for agents with no binding yet.
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) {
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
// false red alarm across every host.
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
// No agent enrolled at all is a neutral fact about most hosts, not a
// fault -- red here made a directory of ordinary hosts look like an
// outage. Red is reserved for "enrolled, and not connected".
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) {
// Now distinguishable from "never existed", because the enrollment row
// outlives the connection.
const seen = a.last_seen ? ' — last seen ' + timeAgo(new Date(a.last_seen * 1000).toISOString()) : '';
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 || {};
@@ -789,29 +752,154 @@
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
}
// Metrics tab body for the resource modal (snapshot of the joined agent).
function metricsTabHtml(agent) {
// 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 metrics.</p></div>';
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) ? t.gpu_usage_percent + '%' : 'N/A';
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? fmtNum(t.gpu_usage_percent) + '%' : 'N/A';
const lastSeenIso = agent.lastSeen || (agent.last_seen ? new Date(agent.last_seen * 1000).toISOString() : '');
const lastSeenStr = timeAgo(lastSeenIso) || 'never';
// RAM details
const ram = t.ram_details || d.ram_details || {};
const totalRamBytes = ram.total_bytes || (d.ram_total_gb ? d.ram_total_gb * 1024 * 1024 * 1024 : 0);
const usedRamBytes = ram.used_bytes || (totalRamBytes * (t.ram_usage_percent || 0) / 100);
const bufRamBytes = ram.buffers_cache_bytes || 0;
const freeRamBytes = ram.free_bytes || Math.max(0, totalRamBytes - usedRamBytes - bufRamBytes);
const usedRamPct = ram.used_percent != null ? ram.used_percent : (t.ram_usage_percent || 0);
const bufRamPct = ram.buffers_cache_percent != null ? ram.buffers_cache_percent : 0;
const freeRamPct = ram.free_percent != null ? ram.free_percent : Math.max(0, 100 - usedRamPct - bufRamPct);
// CPU details
const cpuDet = t.cpu_details || d.cpu_details || {};
const cpuModel = cpuDet.model || d.cpu || 'Unknown CPU';
const cpuCores = cpuDet.cores || 'N/A';
const cpuThreads = cpuDet.threads || 'N/A';
const cpuSpeed = cpuDet.mhz ? (cpuDet.mhz >= 1000 ? (cpuDet.mhz / 1000).toFixed(2) + ' GHz' : cpuDet.mhz.toFixed(0) + ' MHz') : '';
// Disks
const disks = t.disks || d.disks || [];
let disksHtml = '';
if (disks.length > 0) {
disksHtml = `<div class="table-responsive"><table class="table table-sm text-center small mb-0"><thead><tr><th>Mount</th><th>Type</th><th>FS</th><th>Usage</th><th>Total</th></tr></thead><tbody>` +
disks.map(dk => `<tr>
<td><code>${esc(dk.mountpoint)}</code></td>
<td><span class="badge bg-outline-secondary border text-dark">${esc(dk.drivetype || 'Disk')}</span></td>
<td><span class="badge bg-light text-dark border">${esc(dk.fstype || 'N/A')}</span></td>
<td>${fmtNum(dk.usage_percent)}%</td>
<td>${fmtSize(dk.total_bytes)}</td>
</tr>`).join('') + `</tbody></table></div>`;
} else {
disksHtml = `<div class="small">Disk Usage: <strong>${fmtNum(t.disk_usage_percent ?? 0)}%</strong> ${bar(t.disk_usage_percent)}</div>`;
}
return `<div class="p-3">
<div class="mb-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
<h5 class="mb-0">${esc(agent.name || agent.hostname || 'unknown')} ${online}</h5>
<small class="text-muted">Last seen ${lastSeenStr}</small>
</div>
<div class="row g-3">
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
<!-- 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> Disks & Storage</h6>
</div>
<div class="card-body p-2">
${disksHtml}
</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>
<hr><h6>Discovery</h6>
<!-- 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>
@@ -844,6 +932,44 @@
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 {
@@ -1532,18 +1658,23 @@
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) {
$card.hide();
$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>`);
});
$card.show();
}
function renderSecretsTable() {
@@ -1596,6 +1727,7 @@
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');
@@ -1605,17 +1737,25 @@
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;
rawResourceSecretsMap[key] = val || '';
$('#new-secret-key').val('');
$('#new-secret-val').val('');
$('#gen-secret-notice').hide();
await saveResourceSecretsMap();
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() {
const childKey = $('#inherit-child-key').val().trim();
const inheritVal = $('#inherit-parent-select').val();
const resourceId = $('#res-id').val();
if (!childKey) {
app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning');
@@ -1629,30 +1769,32 @@
app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning');
return;
}
if (!resourceId) return;
rawResourceSecretsMap[childKey] = inheritVal;
$('#inherit-child-key').val('');
await saveResourceSecretsMap();
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;
delete rawResourceSecretsMap[key];
await saveResourceSecretsMap();
}
async function saveResourceSecretsMap() {
const resourceId = $('#res-id').val();
if (!resourceId) return;
try {
app.messages.action('Saving secrets to OpenBao...', $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: rawResourceSecretsMap });
app.messages.action('Secret saved to OpenBao successfully!', $('#secrets-tab-container'), 'success');
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 save secrets to OpenBao', $('#secrets-tab-container'), 'danger');
app.messages.action(err.message || 'Failed to delete secret from OpenBao', $('#secrets-tab-container'), 'danger');
}
}
@@ -2644,15 +2786,35 @@
values = values || {};
var html = '';
schema.forEach(function(f) {
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"';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
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;
}
@@ -2661,7 +2823,16 @@
var schema = t && t.configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
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() {
+37 -10
View File
@@ -142,16 +142,37 @@
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3">' +
'<label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
var isChecked = val === true || val === 'true' || val === 1 || val === '1' || (val === 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(val || f.default || '').trim();
var sites = window.availableSites || [];
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="' + app.util.escapeHtml(s.name) + '"' + sel + '>' + app.util.escapeHtml(s.name) + ' (' + app.util.escapeHtml(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 {
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
}
});
return html;
}
@@ -210,7 +231,13 @@
if (!schema) return out;
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) out[f.key] = el.value;
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;
}