release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation
This commit is contained in:
@@ -1336,6 +1336,78 @@ All endpoints require authentication and `app_sso_admin` membership. Runtime con
|
|||||||
|
|
||||||
**Response:** `{ "success": true }`
|
**Response:** `{ "success": true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subtype Driver Operations Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/directory-admin/resources`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission.
|
||||||
|
|
||||||
|
### Get Subtype Driver Metrics
|
||||||
|
|
||||||
|
**`GET /api/directory-admin/resources/:id/driver-metrics`**
|
||||||
|
|
||||||
|
Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"metrics": {
|
||||||
|
"status": "online",
|
||||||
|
"driver": "database",
|
||||||
|
"subType": "redis",
|
||||||
|
"redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Execute Subtype Driver Action
|
||||||
|
|
||||||
|
**`POST /api/directory-admin/resources/:id/driver-action`**
|
||||||
|
|
||||||
|
Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "restart",
|
||||||
|
"params": { "serviceName": "emby-server" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"result": { "status": "ok", "driver": "docker_socket", "action": "restart" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Subtype Driver Logs
|
||||||
|
|
||||||
|
**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`**
|
||||||
|
|
||||||
|
Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"logs": "[docker logs --tail 100 emby-server]\nContainer initialized..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return errors in this format:
|
All endpoints return errors in this format:
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
# v1.32.0 - 2026-08-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Subtype Management & Metrics Drivers Engine.** Built a 4-tier driver resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||||
|
- **Subtype Operations APIs.** Exposed `/api/directory-admin/resources/:id/driver-metrics`, `driver-action`, and `driver-logs` endpoints.
|
||||||
|
- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance, resolving explicit pointers (`INHERIT:<parentSlug>:<parentKey>`) without exposing sibling directory secrets.
|
||||||
|
- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item.
|
||||||
|
- **Multi-Secret Key Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources/<slug>/conf` with per-key merging and deletion.
|
||||||
|
- **Cross-Platform Agent Packaging.** Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths.
|
||||||
|
- **Dockerfile Module Inclusion.** Included `COPY nodejs/drivers ./drivers` in `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution.
|
||||||
|
|
||||||
# v1.31.0 - 2026-08-07
|
# v1.31.0 - 2026-08-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ COPY nodejs/app.js ./
|
|||||||
COPY nodejs/bin ./bin
|
COPY nodejs/bin ./bin
|
||||||
COPY nodejs/conf ./conf
|
COPY nodejs/conf ./conf
|
||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
|
COPY nodejs/drivers ./drivers
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
COPY nodejs/models ./models
|
||||||
COPY nodejs/routes ./routes
|
COPY nodejs/routes ./routes
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ COPY nodejs/app.js ./
|
|||||||
COPY nodejs/bin ./bin
|
COPY nodejs/bin ./bin
|
||||||
COPY nodejs/conf ./conf
|
COPY nodejs/conf ./conf
|
||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
|
COPY nodejs/drivers ./drivers
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
COPY nodejs/models ./models
|
||||||
# Without this the discovery/plugin suites cannot even load their subject and
|
# Without this the discovery/plugin suites cannot even load their subject and
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
same directory, so you don't maintain a second user database for them.
|
same directory, so you don't maintain a second user database for them.
|
||||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||||
drive the management API from scripts or CI, scoped to their own permissions.
|
drive the management API from scripts or CI, scoped to their own permissions.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows.
|
||||||
the pieces separately against your own LDAP/Redis via `app_*` env config.
|
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||||
|
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers.
|
||||||
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
||||||
|
|
||||||
## Why this over the alternatives
|
## Why this over the alternatives
|
||||||
|
|||||||
@@ -367,3 +367,35 @@ Ordered by how much they unblock:
|
|||||||
conventions, not schema changes; the json column already holds them.
|
conventions, not schema changes; the json column already holds them.
|
||||||
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
||||||
freshness; trivial once surfaced).
|
freshness; trivial once surfaced).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Subtype Management & Metrics Drivers Architecture
|
||||||
|
|
||||||
|
The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols.
|
||||||
|
|
||||||
|
### Subtype Matrix & Drivers
|
||||||
|
|
||||||
|
| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState |
|
||||||
|
| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O |
|
||||||
|
| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status |
|
||||||
|
| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels |
|
||||||
|
| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases |
|
||||||
|
| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP |
|
||||||
|
| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions |
|
||||||
|
|
||||||
|
*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).*
|
||||||
|
|
||||||
|
### 4-Tier Driver Resolution Engine
|
||||||
|
1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource.
|
||||||
|
2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver).
|
||||||
|
3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls.
|
||||||
|
4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts.
|
||||||
|
|
||||||
|
### Subtype Operations Endpoints
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||||
|
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`)
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`)
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -6,12 +6,25 @@ nav_order: 5
|
|||||||
|
|
||||||
# Theta Agent & Endpoint Management
|
# Theta Agent & Endpoint Management
|
||||||
|
|
||||||
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2)
|
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. 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.
|
||||||
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
|
## Supported Architectures & Operating Systems
|
||||||
administrative management.
|
|
||||||
|
The agent is compiled for 7 target platform binaries with zero external runtime dependencies:
|
||||||
|
|
||||||
|
| Operating System | Architecture | Binary Name | Typical Target Devices |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors |
|
||||||
|
| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra |
|
||||||
|
| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways |
|
||||||
|
| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop |
|
||||||
|
| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro |
|
||||||
|
| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs |
|
||||||
|
| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) |
|
||||||
|
|
||||||
|
The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,32 @@ The inventory graph isn't just documentation — other components read it to mak
|
|||||||
|
|
||||||
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
||||||
|
|
||||||
|
## Subtype Management & Metrics Drivers Architecture
|
||||||
|
|
||||||
|
The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management:
|
||||||
|
|
||||||
|
1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`).
|
||||||
|
2. **Specialized Subtype Drivers**:
|
||||||
|
- `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls.
|
||||||
|
- `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks.
|
||||||
|
- `DbDriver`: `postgresql`, `redis`, `openbao_vault`.
|
||||||
|
- `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`.
|
||||||
|
- `K8sDriver`: `k8s_pod`, `k8s_deployment`.
|
||||||
|
3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls.
|
||||||
|
4. **Unmanaged Fallback**: Reports unmanaged status cleanly.
|
||||||
|
|
||||||
|
### Subtype Operations API
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||||
|
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`)
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`)
|
||||||
|
|
||||||
|
## Explicit Secret Inheritance Mode
|
||||||
|
|
||||||
|
Resource secrets stored in OpenBao (`secret/data/resources/<slug>/conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage:
|
||||||
|
|
||||||
|
- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed.
|
||||||
|
- **Explicit Assignment**: Secret pointers (`INHERIT:<parentSlug>:<parentKey>`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||||
|
|||||||
+9
-37
@@ -10,48 +10,20 @@ description: OpenBao-backed personal, shared, and external-app secret storage bu
|
|||||||
|
|
||||||
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||||
|
|
||||||
## Usage
|
## Location & Access
|
||||||
|
|
||||||
You can access the Vault UI from the application's top navigation bar.
|
- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps/<name>/*`.
|
||||||
|
- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources/<slug>/conf`.
|
||||||
|
|
||||||
### Creating Secrets
|
## External App Tokens (Admin)
|
||||||
|
|
||||||
1. Click on the **New Secret** button.
|
The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao.
|
||||||
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
|
||||||
3. Enter the **Secret Data** in JSON format. For example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "supersecretpassword123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
4. Click **Save Secret**.
|
|
||||||
|
|
||||||
### Reading and Editing Secrets
|
1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*`.
|
||||||
|
3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically.
|
||||||
|
|
||||||
* To view a secret, click on its name in the **Secrets List**.
|
The token is strictly scoped to `secret/apps/<name>/*` (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
|
||||||
|
|
||||||
### OpenBao Integration
|
|
||||||
|
|
||||||
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
|
|
||||||
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
|
|
||||||
mode, which auto-unseals with an in-memory store and loses everything on
|
|
||||||
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
|
|
||||||
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
|
|
||||||
way as the rest of the app (session cookie or a personal API token) — the
|
|
||||||
server resolves your OpenBao access itself and injects the right scoped
|
|
||||||
token; you never see or handle a raw OpenBao token as a UI user.
|
|
||||||
|
|
||||||
## Apps tab (admin)
|
|
||||||
|
|
||||||
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
|
||||||
|
|
||||||
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
|
||||||
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
|
||||||
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
|
||||||
|
|
||||||
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
|
||||||
|
|
||||||
## Shared tab
|
## Shared tab
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -99,6 +99,7 @@ class Agent extends Model {
|
|||||||
delete data.tokenHash;
|
delete data.tokenHash;
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null,
|
||||||
connected: !!(liveState && liveState.connected),
|
connected: !!(liveState && liveState.connected),
|
||||||
// "Online" is a live-connection fact, not a stored one. A row with a
|
// "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.
|
// last_seen from an hour ago is an installed agent that is down.
|
||||||
|
|||||||
@@ -195,11 +195,12 @@ class Resource extends Model {
|
|||||||
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
||||||
// resources (Host, Cluster, Site, etc.).
|
// resources (Host, Cluster, Site, etc.).
|
||||||
static async findAllAncestors(resourceId, visited = new Set()) {
|
static async findAllAncestors(resourceId, visited = new Set()) {
|
||||||
if (visited.has(resourceId)) return [];
|
if (!resourceId || visited.has(resourceId)) return [];
|
||||||
visited.add(resourceId);
|
visited.add(resourceId);
|
||||||
|
|
||||||
const ancestors = [];
|
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) {
|
for (const edge of parentEdges) {
|
||||||
const parent = await this.get(edge.parentId).catch(() => null);
|
const parent = await this.get(edge.parentId).catch(() => null);
|
||||||
if (!parent) continue;
|
if (!parent) continue;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ module.exports = {
|
|||||||
// and linked to the service they implement instead of arriving as
|
// and linked to the service they implement instead of arriving as
|
||||||
// unmanaged strangers a fresh install has to triage.
|
// unmanaged strangers a fresh install has to triage.
|
||||||
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
{ 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
|
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
|
||||||
// instead of as roots.
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true }
|
||||||
],
|
],
|
||||||
|
|
||||||
validate: async (config) => {
|
validate: async (config) => {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ module.exports = {
|
|||||||
name: 'Nmap Network Scan',
|
name: 'Nmap Network Scan',
|
||||||
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||||
configSchema: [
|
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) => {
|
validate: async (config) => {
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ module.exports = {
|
|||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
{ 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: '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
|
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ module.exports = {
|
|||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
||||||
{ key: 'user', label: 'Username', type: 'text', required: true },
|
{ 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);
|
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
|
||||||
// its agent.yml.
|
// 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) ──
|
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||||
// This is a plain Express Router exported directly so app.js can
|
// This is a plain Express Router exported directly so app.js can
|
||||||
|
|||||||
@@ -247,6 +247,9 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
if (parents.length > 0) req.body.hostId = parents[0].id;
|
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) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
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 {
|
try {
|
||||||
// Validate before loading anything -- a rejected body should never have
|
// Validate before loading anything -- a rejected body should never have
|
||||||
// touched the store.
|
// 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) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
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 parentSecrets = [];
|
||||||
const seenAncestors = new Set();
|
const seenAncestors = new Set();
|
||||||
|
|
||||||
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
||||||
const sites = await Resource.list({ where: { kind: 'site' } }).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;
|
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
|
||||||
seenAncestors.add(parent.id);
|
seenAncestors.add(parent.id);
|
||||||
|
|
||||||
@@ -664,14 +676,18 @@ router.get('/resources/:id/secrets', async (req, res, next) => {
|
|||||||
const parentBody = await parentR.json().catch(() => ({}));
|
const parentBody = await parentR.json().catch(() => ({}));
|
||||||
const pMap = (parentBody.data && parentBody.data.data) || {};
|
const pMap = (parentBody.data && parentBody.data.data) || {};
|
||||||
for (const pKey of Object.keys(pMap)) {
|
for (const pKey of Object.keys(pMap)) {
|
||||||
|
const pVal = String(pMap[pKey] || '');
|
||||||
|
// Ancestor's own secrets (not pointers) are candidates for explicit inheritance
|
||||||
|
if (!pVal.startsWith('INHERIT:')) {
|
||||||
parentSecrets.push({
|
parentSecrets.push({
|
||||||
parentSlug: parent.slug,
|
parentSlug: parent.slug,
|
||||||
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`,
|
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`,
|
||||||
key: pKey
|
key: pKey
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
|
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
@@ -681,25 +697,38 @@ router.post('/resources/:id/secrets', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const resource = await Resource.get(req.params.id);
|
const resource = await Resource.get(req.params.id);
|
||||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
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)
|
// Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved
|
||||||
for (const key of Object.keys(secrets)) {
|
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)) {
|
if (!SECRET_KEY_REGEX.test(key)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
|
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 r = await baoConf.request('POST', path, { data: currentMap });
|
||||||
const path = `secret/data/resources/${resource.slug}/conf`;
|
|
||||||
const r = await baoConf.request('POST', path, { data: secrets });
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,4 +766,37 @@ router.post('/resources/:id/grants', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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;
|
module.exports = router;
|
||||||
|
|||||||
+1
-13
@@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/vault', function(req, res) {
|
router.get('/vault', function(req, res) {
|
||||||
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
res.redirect('/conf');
|
||||||
// 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',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||||
|
|||||||
@@ -19,9 +19,42 @@ function isDescendant(candidateId, rootId, edges) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DiscoveryReconciler {
|
class DiscoveryReconciler {
|
||||||
static async reconcile(sourceName, payload) {
|
static async reconcile(sourceName, payload, options = {}) {
|
||||||
const { resources = [], edges = [] } = payload;
|
const { resources = [], edges = [] } = payload;
|
||||||
let newDevices = 0;
|
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 normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||||
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||||
@@ -35,6 +68,7 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
for (const res of resources) {
|
for (const res of resources) {
|
||||||
if (!res.metadata) res.metadata = {};
|
if (!res.metadata) res.metadata = {};
|
||||||
|
if (autoPromote) res.metadata.managed = true;
|
||||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
let existing = null;
|
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) {
|
if (newDevices > 0) {
|
||||||
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -82,7 +82,7 @@ async function runPluginJob(instanceId) {
|
|||||||
};
|
};
|
||||||
const payload = await runFn(cfg);
|
const payload = await runFn(cfg);
|
||||||
if (instance.category === 'discovery') {
|
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') });
|
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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.
|
// Every enrolled agent, connected or not.
|
||||||
async listAgents() {
|
async listAgents() {
|
||||||
const rows = await Agent.list();
|
const rows = await Agent.list();
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ module.exports = {
|
|||||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
{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: '/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']},
|
||||||
// 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']},
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// grant) propagate immediately because OpenBao parses policy content at use.
|
||||||
async function ensurePolicy(name, hcl) {
|
async function ensurePolicy(name, hcl) {
|
||||||
|
try {
|
||||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
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}`);
|
|
||||||
}
|
|
||||||
if (existing.status === 200) {
|
if (existing.status === 200) {
|
||||||
const body = await existing.json().catch(() => null);
|
const body = await existing.json().catch(() => null);
|
||||||
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
|
if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint a token through a token role with the given policies. Returns
|
// Mint a token through a token role with the given policies. Returns
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
loadProxyConf();
|
loadProxyConf();
|
||||||
loadTos();
|
loadTos();
|
||||||
loadMessagingPlugins();
|
loadMessagingPlugins();
|
||||||
|
loadApps();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadConf() {
|
async function loadConf() {
|
||||||
@@ -302,6 +303,67 @@
|
|||||||
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
@@ -331,6 +393,11 @@
|
|||||||
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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">
|
<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">
|
<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
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<!-- 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/<name>/*</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 -->
|
<!-- Terms of Service Tab -->
|
||||||
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
|||||||
+242
-71
@@ -581,7 +581,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inherit Parent Secret Card -->
|
<!-- 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>
|
<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="row g-2 align-items-center">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -593,7 +593,7 @@
|
|||||||
<select class="form-select form-select-sm" id="inherit-parent-select"></select>
|
<select class="form-select form-select-sm" id="inherit-parent-select"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 pt-3">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,7 +613,7 @@
|
|||||||
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
||||||
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
||||||
{id: 'secrets', label: 'Secrets & OpenBao', bodyHtml: secretsTabHtml},
|
{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: {
|
footer: {
|
||||||
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
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 allGroups = [];
|
||||||
var allEdges = [];
|
var allEdges = [];
|
||||||
var rawResources = [];
|
var rawResources = [];
|
||||||
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
|
|
||||||
var accessSummary = {};
|
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;
|
var promoteSlug = null;
|
||||||
|
|
||||||
$(document).ready(async function() {
|
$(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 agentsByHost = {};
|
||||||
var agentsByResource = {};
|
var agentsByResource = {};
|
||||||
var agentsById = {};
|
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;
|
var agentsUnavailable = false;
|
||||||
|
|
||||||
async function loadResources() {
|
async function loadResources() {
|
||||||
@@ -680,12 +670,7 @@
|
|||||||
app.api.get('directory-admin/resources'),
|
app.api.get('directory-admin/resources'),
|
||||||
app.api.get('directory-admin/groups'),
|
app.api.get('directory-admin/groups'),
|
||||||
app.api.get('directory-admin/edges'),
|
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: {}}; }),
|
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')
|
app.api.get('agent/nodes')
|
||||||
.then(function(res){ agentsUnavailable = false; return res; })
|
.then(function(res){ agentsUnavailable = false; return res; })
|
||||||
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
|
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
|
||||||
@@ -706,7 +691,6 @@
|
|||||||
|
|
||||||
rawResources = [];
|
rawResources = [];
|
||||||
for (const r of resResources.results) {
|
for (const r of resResources.results) {
|
||||||
// Compute hostName from edges
|
|
||||||
r.hostName = '—';
|
r.hostName = '—';
|
||||||
r.parentId = null;
|
r.parentId = null;
|
||||||
const parentEdge = allEdges.find(e => e.childId === r.id);
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||||
@@ -720,27 +704,17 @@
|
|||||||
|
|
||||||
renderTable();
|
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) {
|
loadDirectoryUsers().then(function(users) {
|
||||||
$('#access-uid-list').html(users.map(function(u) {
|
$('#access-uid-list').html(users.map(function(u) {
|
||||||
return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
|
return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
|
||||||
}).join(''));
|
}).join(''));
|
||||||
}).catch(function(){ /* datalist is a convenience only */ });
|
}).catch(function(){});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.toast('Failed to load data', 'danger');
|
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) {
|
function indexAgents(agents) {
|
||||||
agentsByHost = {};
|
agentsByHost = {};
|
||||||
agentsByResource = {};
|
agentsByResource = {};
|
||||||
@@ -756,31 +730,20 @@
|
|||||||
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
|
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
|
||||||
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
|
function 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) {
|
function attachAgentStatus(n) {
|
||||||
n.isHost = true;
|
n.isHost = true;
|
||||||
// Bound agent first; hostname match only for agents with no binding yet.
|
|
||||||
const name = (n.name || '').toLowerCase();
|
const name = (n.name || '').toLowerCase();
|
||||||
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
|
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
|
||||||
const a = agentsByResource[n.id] || agentsByHost[name] || (slug && agentsByHost[slug]);
|
const a = agentsByResource[n.id] || agentsByHost[name] || (slug && agentsByHost[slug]);
|
||||||
n.agent = a || null;
|
n.agent = a || null;
|
||||||
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
||||||
if (!a) {
|
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; }
|
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;
|
n.agentColor = '#adb5bd'; n.agentStatusTitle = 'No theta-agent enrolled'; return;
|
||||||
}
|
}
|
||||||
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
|
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
|
||||||
if (!a.isOnline) {
|
if (!a.isOnline) {
|
||||||
// Now distinguishable from "never existed", because the enrollment row
|
const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
|
||||||
// outlives the connection.
|
|
||||||
const seen = a.last_seen ? ' — last seen ' + timeAgo(new Date(a.last_seen * 1000).toISOString()) : '';
|
|
||||||
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
|
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
|
||||||
}
|
}
|
||||||
const t = a.lastTelemetry || {};
|
const t = a.lastTelemetry || {};
|
||||||
@@ -789,29 +752,154 @@
|
|||||||
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics tab body for the resource modal (snapshot of the joined agent).
|
// Agent tab body for the resource modal.
|
||||||
function metricsTabHtml(agent) {
|
function agentTabHtml(agent) {
|
||||||
if (!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 d = agent.lastDiscovery || {};
|
||||||
const t = agent.lastTelemetry || {};
|
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 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 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">
|
return `<div class="p-3">
|
||||||
<div class="mb-3 d-flex justify-content-between align-items-center">
|
<div class="mb-3 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
|
<h5 class="mb-0">${esc(agent.name || agent.hostname || 'unknown')} ${online}</h5>
|
||||||
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
|
<small class="text-muted">Last seen ${lastSeenStr}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
|
<!-- Memory Card (Matching user screenshot design) -->
|
||||||
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
|
<div class="card mb-3 shadow-sm border">
|
||||||
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
|
<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">GPU <strong>${gpu}</strong></div>
|
||||||
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
|
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
|
||||||
</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="row small text-muted">
|
||||||
<div class="col-6">OS: ${esc(d.os || '')}</div>
|
<div class="col-6">OS: ${esc(d.os || '')}</div>
|
||||||
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
|
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
|
||||||
@@ -844,6 +932,44 @@
|
|||||||
return bools.map(([n, on]) => badge(n, !!on)).join('') + scLine;
|
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.
|
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
|
||||||
async function refreshAgents() {
|
async function refreshAgents() {
|
||||||
try {
|
try {
|
||||||
@@ -1532,18 +1658,23 @@
|
|||||||
function populateParentSecretsDropdown() {
|
function populateParentSecretsDropdown() {
|
||||||
const $card = $('#inherit-secret-card');
|
const $card = $('#inherit-secret-card');
|
||||||
const $select = $('#inherit-parent-select').empty();
|
const $select = $('#inherit-parent-select').empty();
|
||||||
|
const $btn = $('#btn-inherit-secret');
|
||||||
|
|
||||||
|
$card.show();
|
||||||
|
|
||||||
if (!currentParentSecretsList || currentParentSecretsList.length === 0) {
|
if (!currentParentSecretsList || currentParentSecretsList.length === 0) {
|
||||||
$card.hide();
|
$select.append('<option value="">(No parent/ancestor secrets available in OpenBao)</option>');
|
||||||
|
$btn.prop('disabled', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$btn.prop('disabled', false);
|
||||||
|
$select.append('<option value="">-- Select Parent Resource Secret --</option>');
|
||||||
currentParentSecretsList.forEach(p => {
|
currentParentSecretsList.forEach(p => {
|
||||||
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
|
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
|
||||||
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
|
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
|
||||||
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
|
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
|
||||||
});
|
});
|
||||||
$card.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSecretsTable() {
|
function renderSecretsTable() {
|
||||||
@@ -1596,6 +1727,7 @@
|
|||||||
const keyEl = $('#new-secret-key')[0];
|
const keyEl = $('#new-secret-key')[0];
|
||||||
const key = $('#new-secret-key').val().trim();
|
const key = $('#new-secret-key').val().trim();
|
||||||
const val = $('#new-secret-val').val();
|
const val = $('#new-secret-val').val();
|
||||||
|
const resourceId = $('#res-id').val();
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
app.messages.action('Please enter a secret key name (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'warning');
|
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');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!resourceId) return;
|
||||||
|
|
||||||
rawResourceSecretsMap[key] = val || '';
|
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-key').val('');
|
||||||
$('#new-secret-val').val('');
|
$('#new-secret-val').val('');
|
||||||
$('#gen-secret-notice').hide();
|
$('#gen-secret-notice').hide();
|
||||||
await saveResourceSecretsMap();
|
loadResourceSecrets(resourceId);
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.action(err.message || 'Failed to save secret to OpenBao', $('#secrets-tab-container'), 'danger');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inheritParentSecret() {
|
async function inheritParentSecret() {
|
||||||
const childKey = $('#inherit-child-key').val().trim();
|
const childKey = $('#inherit-child-key').val().trim();
|
||||||
const inheritVal = $('#inherit-parent-select').val();
|
const inheritVal = $('#inherit-parent-select').val();
|
||||||
|
const resourceId = $('#res-id').val();
|
||||||
|
|
||||||
if (!childKey) {
|
if (!childKey) {
|
||||||
app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning');
|
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');
|
app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!resourceId) return;
|
||||||
|
|
||||||
rawResourceSecretsMap[childKey] = inheritVal;
|
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('');
|
$('#inherit-child-key').val('');
|
||||||
await saveResourceSecretsMap();
|
loadResourceSecrets(resourceId);
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.action(err.message || 'Failed to save inherited secret', $('#secrets-tab-container'), 'danger');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSecretKey(key) {
|
async function deleteSecretKey(key) {
|
||||||
const confirmed = await app.messages.confirm(`Delete secret '${key}' from OpenBao?`, $('#secrets-tab-container'), 'danger');
|
const confirmed = await app.messages.confirm(`Delete secret '${key}' from OpenBao?`, $('#secrets-tab-container'), 'danger');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
delete rawResourceSecretsMap[key];
|
|
||||||
await saveResourceSecretsMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveResourceSecretsMap() {
|
|
||||||
const resourceId = $('#res-id').val();
|
const resourceId = $('#res-id').val();
|
||||||
if (!resourceId) return;
|
if (!resourceId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app.messages.action('Saving secrets to OpenBao...', $('#secrets-tab-container'), 'info');
|
app.messages.action(`Deleting secret '${key}' from OpenBao...`, $('#secrets-tab-container'), 'info');
|
||||||
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: rawResourceSecretsMap });
|
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { action: 'delete', key });
|
||||||
app.messages.action('Secret saved to OpenBao successfully!', $('#secrets-tab-container'), 'success');
|
app.messages.action(`Secret '${key}' deleted successfully from OpenBao.`, $('#secrets-tab-container'), 'success');
|
||||||
loadResourceSecrets(resourceId);
|
loadResourceSecrets(resourceId);
|
||||||
} catch (err) {
|
} 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 || {};
|
values = values || {};
|
||||||
var html = '';
|
var html = '';
|
||||||
schema.forEach(function(f) {
|
schema.forEach(function(f) {
|
||||||
|
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||||
|
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
|
||||||
|
var isChecked = values[f.key] === true || values[f.key] === 'true' || values[f.key] === 1 || values[f.key] === '1' || (values[f.key] === undefined && f.default !== false);
|
||||||
|
html += '<div class="mb-3 form-check">' +
|
||||||
|
'<input type="checkbox" class="form-check-input" id="' + prefix + f.key + '"' + (isChecked ? ' checked' : '') + '>' +
|
||||||
|
'<label class="form-check-label fw-bold" for="' + prefix + f.key + '">' + label + '</label>' +
|
||||||
|
'</div>';
|
||||||
|
} else if (f.type === 'site_select' || f.key === 'location') {
|
||||||
|
var selectedVal = String(values[f.key] != null ? values[f.key] : (f.default || '')).trim();
|
||||||
|
var sites = rawResources.filter(r => r.kind === 'site');
|
||||||
|
var siteOpts = '<option value="">(Default Site)</option>';
|
||||||
|
sites.forEach(function(s) {
|
||||||
|
var sel = (s.name === selectedVal || s.slug === selectedVal || (!selectedVal && s.slug === 'site-default')) ? ' selected' : '';
|
||||||
|
siteOpts += '<option value="' + esc(s.name) + '"' + sel + '>' + esc(s.name) + ' (' + esc(s.slug) + ')</option>';
|
||||||
|
});
|
||||||
|
html += '<div class="mb-3">' +
|
||||||
|
'<label class="form-label fw-bold">' + label + '</label>' +
|
||||||
|
'<select class="form-select" id="' + prefix + f.key + '">' + siteOpts + '</select>' +
|
||||||
|
'</div>';
|
||||||
|
} else {
|
||||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||||
var req = (f.required && !f.secret) ? ' required' : '';
|
var req = (f.required && !f.secret) ? ' required' : '';
|
||||||
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
|
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
|
||||||
var val = '';
|
var val = '';
|
||||||
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
|
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"';
|
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 fw-bold">' + label + '</label>' +
|
||||||
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>';
|
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -2661,7 +2823,16 @@
|
|||||||
var schema = t && t.configSchema;
|
var schema = t && t.configSchema;
|
||||||
var out = {};
|
var out = {};
|
||||||
if (!schema) return 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;
|
return out;
|
||||||
}
|
}
|
||||||
function dpRenderFields() {
|
function dpRenderFields() {
|
||||||
|
|||||||
@@ -142,16 +142,37 @@
|
|||||||
if (!includeSecrets && f.secret) return;
|
if (!includeSecrets && f.secret) return;
|
||||||
var val = v[f.key];
|
var val = v[f.key];
|
||||||
if (f.secret) val = '';
|
if (f.secret) val = '';
|
||||||
|
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||||
|
|
||||||
|
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
|
||||||
|
var isChecked = 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 = '';
|
if (val === undefined || val === null) val = '';
|
||||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||||
var req = f.required ? ' required' : '';
|
var req = f.required ? ' required' : '';
|
||||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
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">' +
|
html += '<div class="mb-3">' +
|
||||||
'<label class="form-label">' + label + '</label>' +
|
'<label class="form-label fw-bold">' + label + '</label>' +
|
||||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '"') + '"' + req + ph + '>';
|
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '"') + '"' + req + ph + '>';
|
||||||
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
|
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -210,7 +231,13 @@
|
|||||||
if (!schema) return out;
|
if (!schema) return out;
|
||||||
schema.forEach(function(f) {
|
schema.forEach(function(f) {
|
||||||
var el = document.getElementById(prefix + f.key);
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user