Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5b1736b8 | ||
|
|
cd3d0cd8ff | ||
|
|
7ebb1b1a01 | ||
|
|
ae54927210 | ||
|
|
9a1998288e | ||
|
|
503de258b8 | ||
|
|
87dd2712a0 | ||
|
|
8f4883bfcd | ||
|
|
77a94d55d2 | ||
|
|
a468e0f480 | ||
|
|
43d9c0e1d0 | ||
|
|
96a6e8ac6a |
@@ -0,0 +1,56 @@
|
|||||||
|
# DashCaddy AI-Native Vision
|
||||||
|
|
||||||
|
## The Vision
|
||||||
|
DashCaddy should be inherently optimized for AI agents to control it.
|
||||||
|
Users should be able to self-host anything using natural language.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
1. **AI as first-class citizen** — not a bolt-on chatbot, but where the API itself is designed for AI consumption
|
||||||
|
2. **Natural language → deployment** — "host a Plex server" → running container + reverse proxy + DNS + health check
|
||||||
|
3. **Agent-friendly API** — structured responses, semantic error codes, state machines, idempotent operations
|
||||||
|
4. **MCP-native** — DashCaddy should expose itself as an MCP server so any AI agent can control it
|
||||||
|
|
||||||
|
## Architecture Layers
|
||||||
|
|
||||||
|
### Layer 1: Natural Language Intent Router (NEW)
|
||||||
|
`POST /api/v1/ai/intent` — Takes natural language, returns structured action plan
|
||||||
|
- "I want to stream movies" → { category: media-streaming, recommended: [plex, sonarr, radarr] }
|
||||||
|
- "Set up a password manager" → { category: file-sync, recommended: [vaultwarden] }
|
||||||
|
- "Block ads on my network" → { category: home-network, recommended: [adguard] }
|
||||||
|
- "Why is Plex down?" → diagnostics query → { action: health-check, service: plex }
|
||||||
|
|
||||||
|
### Layer 2: MCP Server (NEW)
|
||||||
|
Expose DashCaddy as a Model Context Protocol server so ANY AI agent (Claude, GPT, Gemini, Hermes) can:
|
||||||
|
- List services, containers, health status
|
||||||
|
- Deploy/stop/restart apps
|
||||||
|
- Manage DNS records and Caddyfile routes
|
||||||
|
- Run diagnostics and get structured results
|
||||||
|
- Create backups and restore
|
||||||
|
|
||||||
|
### Layer 3: Structured Action API (EXISTING — needs enhancement)
|
||||||
|
366 existing routes already cover the CRUD surface. Enhancement needed:
|
||||||
|
- Consistent response envelopes (already have `ok()` / `errorResponse()`)
|
||||||
|
- All error responses include machine-readable codes (DC-086 done — 80 codes)
|
||||||
|
- Idempotency keys for mutating operations
|
||||||
|
- Operation receipts (UUID + status tracking)
|
||||||
|
|
||||||
|
### Layer 4: Semantic Service Catalog (EXISTING — DC-104)
|
||||||
|
76 templates with categories, auto-categorization, search.
|
||||||
|
Enhancement: Add intent tags ("movie streaming", "password manager", "ad blocking")
|
||||||
|
|
||||||
|
### Layer 5: Diagnostic Engine (NEW)
|
||||||
|
`POST /api/v1/ai/diagnose` — Structured troubleshooting
|
||||||
|
- "Why is X slow?" → checks: CPU, memory, network, disk I/O, container logs
|
||||||
|
- Returns structured findings with severity + suggested fix
|
||||||
|
- Can auto-apply fixes with user approval
|
||||||
|
|
||||||
|
### Layer 6: Deployment Orchestrator (PARTIAL — DC-103 + wizard)
|
||||||
|
"Deploy Plex" → full automation chain:
|
||||||
|
1. Pull image
|
||||||
|
2. Create container with optimal config
|
||||||
|
3. Generate Caddyfile route (DC-106)
|
||||||
|
4. Create DNS record
|
||||||
|
5. Add to services list
|
||||||
|
6. Start health monitoring
|
||||||
|
7. Configure notifications
|
||||||
|
8. Return ready-to-use URL
|
||||||
@@ -17,19 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **DC-093: Workflow engine retry with exponential backoff.** Actions retry up to 3 times with 2/4/8s delay before giving up. Logs each retry attempt. `exhaustedRetries` field in failure result shows total attempts.
|
- **DC-093: Workflow engine retry with exponential backoff.** Actions retry up to 3 times with 2/4/8s delay before giving up. Logs each retry attempt. `exhaustedRetries` field in failure result shows total attempts.
|
||||||
- **DC-073: Debug request logger.** Logs method, path, status code, and duration when `LOG_LEVEL=debug` env var is set. Off by default in production.
|
- **DC-073: Debug request logger.** Logs method, path, status code, and duration when `LOG_LEVEL=debug` env var is set. Off by default in production.
|
||||||
- **DC-066: End-to-end billing integration test.** Exercises full purchase flow: checkout → webhook → license delivery → activation → Pro unlock. 12 tests covering happy path, 404 before webhook, all 4 catalog products, webhook idempotency.
|
- **DC-066: End-to-end billing integration test.** Exercises full purchase flow: checkout → webhook → license delivery → activation → Pro unlock. 12 tests covering happy path, 404 before webhook, all 4 catalog products, webhook idempotency.
|
||||||
- **DC-076: WebSocket real-time dashboard updates.** `ws://host/api/v1/ws` — bidirectional WebSocket server with subscribe/unsubscribe by event type, JSON message protocol, heartbeat ping/pong, and auto-cleanup of dead connections.
|
|
||||||
- **DC-077: Internationalization (i18n).** Translation system supporting English, Spanish, French, German, and Arabic. `GET /api/v1/i18n/languages`, `GET /api/v1/i18n/translations/:lang`. Accept-Language header detection with quality values. RTL support for Arabic.
|
|
||||||
- **DC-080: Plugin/extension system.** PluginManager loads extensions from `{dataDir}/plugins/` that can register custom service types, notification providers, workflow actions, dashboard widgets, and deploy hooks. Manifest-based with permission declaration.
|
|
||||||
- **DC-071: Error tracking integration.** Sentry-compatible error tracker (opt-in via `ERROR_TRACKING_DSN` env var). Non-blocking, 5s timeout, Express error middleware included.
|
|
||||||
- **DC-086: Structured error codes.** 80 machine-readable error codes across 12 modules (AUTH, CONTAINER, SERVICE, DNS, CADDY, CA, BACKUP, BILL, HEALTH, NETWORK, SYSTEM, GENERAL). Format: `DC-[MODULE]-[NUMBER]`. `errorResponse()` surfaces `code` at top level.
|
|
||||||
- **DC-087: JavaScript SDK + TypeScript types.** Zero-dependency client library (326 lines) covering 39 methods across 7 resource namespaces. API key or session auth, automatic CSRF, 5xx retry with backoff.
|
|
||||||
- **DC-100: Service discovery.** `GET /api/v1/discover` scans running containers, matches against 20 known image patterns, returns suggested service configs with port mappings and existing-service detection.
|
|
||||||
- **DC-103: One-click auto-route adoption.** `POST /api/v1/discover/adopt` creates service entry + Caddyfile reverse_proxy route + DNS record from a discovered container.
|
|
||||||
- **DC-104: App catalog.** `GET /api/v1/catalog` browses 76 curated templates with category filtering, search, and popular badges. 7 auto-detected categories.
|
|
||||||
- **DC-105: Smart defaults wizard.** "What do you want to self-host?" — 6 categories (media, files, network, smart home, development, monitoring), hardware profile limits, cross-category dedup with priority sorting.
|
|
||||||
- **DC-106: Caddyfile-as-code.** Visual reverse proxy builder API — generate Caddyfile blocks from JSON config (TLS, auth, CORS, headers, WebSocket, compression, strip prefix). 5 preset templates.
|
|
||||||
- **DC-107: Disaster recovery.** Full-system backup (services, config, credentials, Caddyfile, themes, assets) with SHA-256 checksum verification. One-click restore with partial-failure handling.
|
|
||||||
- **DC-108: Multi-host fleet management.** Register/deregister remote DashCaddy instances, parallel health probes, multi-host deployment plan generation. API keys stored as SHA-256 hashes.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **DC-082: Command injection eliminated.** All 6 `execSync` calls with string interpolation converted to `execFileSync` with argument arrays in `ca.js` and `self-updater.js`.
|
- **DC-082: Command injection eliminated.** All 6 `execSync` calls with string interpolation converted to `execFileSync` with argument arrays in `ca.js` and `self-updater.js`.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DashCaddy MCP Server — direct handler testing
|
||||||
|
*
|
||||||
|
* Instead of spawning the server process, we test the message handler
|
||||||
|
* logic directly by loading the handler module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// We'll test the protocol handler logic directly
|
||||||
|
// by extracting and testing the response shapes
|
||||||
|
|
||||||
|
describe('DashCaddy MCP Server Tools', () => {
|
||||||
|
// Load the MCP server source and extract tool definitions
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const mcpSource = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'src', 'mcp', 'mcp-server.js'), 'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract tool names from the source
|
||||||
|
const toolNames = [...mcpSource.matchAll(/name: '(dashcaddy_[^']+)'/g)].map(m => m[1]);
|
||||||
|
|
||||||
|
test('defines at least 15 tools', () => {
|
||||||
|
expect(toolNames.length).toBeGreaterThanOrEqual(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes core service management tools', () => {
|
||||||
|
expect(toolNames).toContain('dashcaddy_list_services');
|
||||||
|
expect(toolNames).toContain('dashcaddy_get_service');
|
||||||
|
expect(toolNames).toContain('dashcaddy_check_health');
|
||||||
|
expect(toolNames).toContain('dashcaddy_container_action');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes deployment and catalog tools', () => {
|
||||||
|
expect(toolNames).toContain('dashcaddy_deploy_app');
|
||||||
|
expect(toolNames).toContain('dashcaddy_search_catalog');
|
||||||
|
expect(toolNames).toContain('dashcaddy_discover_services');
|
||||||
|
expect(toolNames).toContain('dashcaddy_wizard_recommend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes system tools', () => {
|
||||||
|
expect(toolNames).toContain('dashcaddy_system_health');
|
||||||
|
expect(toolNames).toContain('dashcaddy_system_metrics');
|
||||||
|
expect(toolNames).toContain('dashcaddy_diagnose');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes DNS and proxy tools', () => {
|
||||||
|
expect(toolNames).toContain('dashcaddy_list_dns');
|
||||||
|
expect(toolNames).toContain('dashcaddy_generate_caddyfile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes backup and fleet tools', () => {
|
||||||
|
expect(toolNames).toContain('dashcaddy_create_backup');
|
||||||
|
expect(toolNames).toContain('dashcaddy_get_backup_status');
|
||||||
|
expect(toolNames).toContain('dashcaddy_list_fleet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each tool has description and inputSchema in source', () => {
|
||||||
|
// Verify the TOOLS array structure by checking patterns in source
|
||||||
|
expect(mcpSource).toContain('inputSchema');
|
||||||
|
expect(mcpSource).toContain('description:');
|
||||||
|
expect(mcpSource).toContain('required:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deploy_app requires templateId parameter', () => {
|
||||||
|
const deploySection = mcpSource.substring(
|
||||||
|
mcpSource.indexOf("name: 'dashcaddy_deploy_app'"),
|
||||||
|
mcpSource.indexOf("name: 'dashcaddy_deploy_app'") + 1000
|
||||||
|
);
|
||||||
|
expect(deploySection).toContain('templateId');
|
||||||
|
expect(deploySection).toContain('required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MCP protocol version is 2024-11-05', () => {
|
||||||
|
expect(mcpSource).toContain('2024-11-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server identifies as dashcaddy', () => {
|
||||||
|
expect(mcpSource).toContain("'dashcaddy'");
|
||||||
|
expect(mcpSource).toContain('1.15.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses JSON-RPC 2.0', () => {
|
||||||
|
expect(mcpSource).toContain('jsonrpc');
|
||||||
|
expect(mcpSource).toContain("'2.0'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports stdio transport', () => {
|
||||||
|
expect(mcpSource).toContain('readline');
|
||||||
|
expect(mcpSource).toContain('process.stdin');
|
||||||
|
expect(mcpSource).toContain('process.stdout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes all MCP methods (initialize, tools/list, tools/call)', () => {
|
||||||
|
expect(mcpSource).toContain("case 'initialize'");
|
||||||
|
expect(mcpSource).toContain("case 'tools/list'");
|
||||||
|
expect(mcpSource).toContain("case 'tools/call'");
|
||||||
|
expect(mcpSource).toContain("case 'resources/list'");
|
||||||
|
expect(mcpSource).toContain("case 'ping'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has error handling for unknown methods', () => {
|
||||||
|
expect(mcpSource).toContain('-32601');
|
||||||
|
expect(mcpSource).toContain('Method not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the AI Intent Router
|
||||||
|
*/
|
||||||
|
const { routeIntent } = require('../../routes/ai-intent');
|
||||||
|
|
||||||
|
describe('AI Intent Router', () => {
|
||||||
|
describe('deploy intents', () => {
|
||||||
|
test('detects "deploy plex"', () => {
|
||||||
|
const result = routeIntent('Deploy Plex');
|
||||||
|
expect(result.intent).toBe('deploy');
|
||||||
|
expect(result.appId).toBe('plex');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects "set up nextcloud"', () => {
|
||||||
|
const result = routeIntent('Set up Nextcloud');
|
||||||
|
expect(result.intent).toBe('deploy');
|
||||||
|
expect(result.appId).toBe('nextcloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects "install gitea"', () => {
|
||||||
|
const result = routeIntent('Can you install Gitea for me?');
|
||||||
|
expect(result.intent).toBe('deploy');
|
||||||
|
expect(result.appId).toBe('gitea');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes deploy info', () => {
|
||||||
|
const result = routeIntent('Deploy Plex');
|
||||||
|
expect(result.appId).toBe('plex');
|
||||||
|
expect(result.action).toBe('dashcaddy_deploy_app');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recommend intents', () => {
|
||||||
|
test('media streaming → recommends Plex', () => {
|
||||||
|
const result = routeIntent('I want to stream movies');
|
||||||
|
expect(result.intent).toBe('recommend');
|
||||||
|
expect(result.categories).toContain('media-streaming');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password manager → recommends Vaultwarden', () => {
|
||||||
|
const result = routeIntent('I need a password manager');
|
||||||
|
expect(result.intent).toBe('recommend');
|
||||||
|
expect(result.response.recommendations[0].app).toBe('vaultwarden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ad blocking → recommends AdGuard', () => {
|
||||||
|
const result = routeIntent('Block ads on my network');
|
||||||
|
expect(result.intent).toBe('recommend');
|
||||||
|
expect(result.response.recommendations[0].app).toBe('adguard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes categories for wizard', () => {
|
||||||
|
const result = routeIntent('I want to stream movies');
|
||||||
|
expect(result.categories).toContain('media-streaming');
|
||||||
|
expect(result.action).toBe('dashcaddy_wizard_recommend');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diagnose intents', () => {
|
||||||
|
test('detects "why is plex down"', () => {
|
||||||
|
const result = routeIntent('Why is Plex down?');
|
||||||
|
expect(result.intent).toBe('diagnose');
|
||||||
|
expect(result.serviceId).toBe('plex');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects "something is broken"', () => {
|
||||||
|
const result = routeIntent('Something is broken with my services');
|
||||||
|
expect(result.intent).toBe('diagnose');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backup intents', () => {
|
||||||
|
test('detects "back up everything"', () => {
|
||||||
|
const result = routeIntent('Back up everything');
|
||||||
|
expect(result.intent).toBe('backup');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects "create a snapshot"', () => {
|
||||||
|
const result = routeIntent('Create a snapshot');
|
||||||
|
expect(result.intent).toBe('backup');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('health intents', () => {
|
||||||
|
test('detects "is everything ok?"', () => {
|
||||||
|
const result = routeIntent('Is everything OK?');
|
||||||
|
expect(result.intent).toBe('health');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects "system check"', () => {
|
||||||
|
const result = routeIntent('Run a system check');
|
||||||
|
expect(result.intent).toBe('health');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('list intents', () => {
|
||||||
|
test('detects "what services am I running?"', () => {
|
||||||
|
const result = routeIntent('What services am I running?');
|
||||||
|
expect(result.intent).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects "show me everything"', () => {
|
||||||
|
const result = routeIntent('Show me everything that\'s deployed');
|
||||||
|
expect(result.intent).toBe('list');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown intents', () => {
|
||||||
|
test('returns fallback for unrecognized input', () => {
|
||||||
|
const result = routeIntent('xyz random gibberish 123');
|
||||||
|
expect(result.intent).toBe('unknown');
|
||||||
|
expect(result.response.suggestions).toBeTruthy();
|
||||||
|
expect(result.response.suggestions.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback includes example queries', () => {
|
||||||
|
const result = routeIntent('hello world');
|
||||||
|
expect(result.response.suggestions.some(s => s.includes('Deploy'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* DashCaddy AI Intent Router
|
||||||
|
*
|
||||||
|
* Takes natural language input and returns structured, actionable intents
|
||||||
|
* that can be executed against the DashCaddy API.
|
||||||
|
*
|
||||||
|
* POST /api/v1/ai/intent
|
||||||
|
* Body: { message: "I want to stream movies", context: {} }
|
||||||
|
* Returns: { intent, confidence, actions, followup }
|
||||||
|
*
|
||||||
|
* The intent router uses pattern matching (not an LLM call) so it works
|
||||||
|
* instantly and offline. For complex queries, it can delegate to an
|
||||||
|
* external LLM via the LLM_PROXY_URL env var.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
|
|
||||||
|
// ─── Intent Pattern Library ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const INTENT_PATTERNS = [
|
||||||
|
// ── Deploy intents ──
|
||||||
|
{
|
||||||
|
intent: 'deploy',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:deploy|install|set up|setup|host|run|start|spin up|launch)\b.*\b(?:plex|jellyfin|emby|sonarr|radarr|nextcloud|gitea|vaultwarden|adguard|wireguard|home.assistant|grafana|prometheus|qbittorrent|transmission|portainer|redis|postgres|mariadb|mongodb|nginx)\b/i,
|
||||||
|
/\b(?:i want|i need|can you|help me|let'?s)\b.*\b(?:deploy|install|set up|host|run)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_deploy_app',
|
||||||
|
extractApp: (msg) => {
|
||||||
|
const apps = ['plex', 'jellyfin', 'emby', 'sonarr', 'radarr', 'prowlarr',
|
||||||
|
'lidarr', 'readarr', 'qbittorrent', 'transmission', 'nextcloud',
|
||||||
|
'vaultwarden', 'gitea', 'adguard', 'pihole', 'wireguard',
|
||||||
|
'home assistant', 'homeassistant', 'grafana', 'prometheus',
|
||||||
|
'portainer', 'redis', 'postgres', 'postgresql', 'mariadb',
|
||||||
|
'mongodb', 'nginx', 'caddy', 'uptime kuma', 'code-server'];
|
||||||
|
for (const app of apps) {
|
||||||
|
if (msg.toLowerCase().includes(app)) return app.replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Streaming/Media intents ──
|
||||||
|
{
|
||||||
|
intent: 'recommend',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:stream|streaming|movie|movies|tv show|tv shows|film|films|watch|media)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_wizard_recommend',
|
||||||
|
suggestCategories: ['media-streaming'],
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'For media streaming, I recommend:',
|
||||||
|
recommendations: [
|
||||||
|
{ app: 'plex', reason: 'Stream movies and TV shows to any device' },
|
||||||
|
{ app: 'sonarr', reason: 'Automatically download TV shows' },
|
||||||
|
{ app: 'radarr', reason: 'Automatically download movies' },
|
||||||
|
{ app: 'qbittorrent', reason: 'Download client for media files' },
|
||||||
|
],
|
||||||
|
question: 'Would you like me to deploy any of these?',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Password manager ──
|
||||||
|
{
|
||||||
|
intent: 'recommend',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:password|passwords|password manager|vaultwarden|bitwarden|1password|lastpass|secure password)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_wizard_recommend',
|
||||||
|
suggestCategories: ['file-sync'],
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'For password management, I recommend:',
|
||||||
|
recommendations: [
|
||||||
|
{ app: 'vaultwarden', reason: 'Self-hosted Bitwarden-compatible password manager' },
|
||||||
|
],
|
||||||
|
question: 'Would you like me to deploy Vaultwarden?',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Ad blocking ──
|
||||||
|
{
|
||||||
|
intent: 'recommend',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:ad block|adblock|block ads|ad blocking|pihole|adguard|dns blocking)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_wizard_recommend',
|
||||||
|
suggestCategories: ['home-network'],
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'For network-wide ad blocking, I recommend:',
|
||||||
|
recommendations: [
|
||||||
|
{ app: 'adguard', reason: 'DNS-level ad blocking for your entire network' },
|
||||||
|
{ app: 'pihole', reason: 'Alternative DNS ad blocker with detailed statistics' },
|
||||||
|
],
|
||||||
|
question: 'Would you like me to set up ad blocking?',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── File storage ──
|
||||||
|
{
|
||||||
|
intent: 'recommend',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:file storage|cloud storage|google drive|dropbox|file sync|nextcloud|owncloud)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_wizard_recommend',
|
||||||
|
suggestCategories: ['file-sync'],
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'For file storage and sync, I recommend:',
|
||||||
|
recommendations: [
|
||||||
|
{ app: 'nextcloud', reason: 'Self-hosted Google Drive replacement' },
|
||||||
|
],
|
||||||
|
question: 'Would you like me to deploy Nextcloud?',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Development ──
|
||||||
|
{
|
||||||
|
intent: 'recommend',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:git|code|develop|programming|ide|vs code|github|self-hosted git)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_wizard_recommend',
|
||||||
|
suggestCategories: ['development'],
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'For development tools, I recommend:',
|
||||||
|
recommendations: [
|
||||||
|
{ app: 'gitea', reason: 'Self-hosted Git with CI/CD pipelines' },
|
||||||
|
{ app: 'code-server', reason: 'VS Code in your browser' },
|
||||||
|
],
|
||||||
|
question: 'Would you like me to deploy any of these?',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Diagnostics ──
|
||||||
|
{
|
||||||
|
intent: 'diagnose',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:why|what'?s wrong|broken|down|not working|slow|error|failing|crashed|unhealthy|diagnose|troubleshoot|debug)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_diagnose',
|
||||||
|
extractService: (msg) => {
|
||||||
|
// Try to extract service name from "why is X down" patterns
|
||||||
|
const match = msg.match(/(?:why is |is |)(\w+)\s+(?:down|slow|broken|not working|failing|crashed)/i);
|
||||||
|
if (match) return match[1].toLowerCase();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'Let me check what\'s going on...',
|
||||||
|
action: 'diagnose',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Backup ──
|
||||||
|
{
|
||||||
|
intent: 'backup',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:backup|back up|save|snapshot|export)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_create_backup',
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'Creating a full system backup now...',
|
||||||
|
action: 'backup',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Health check ──
|
||||||
|
{
|
||||||
|
intent: 'health',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:health|healthy|status|everything ok|all good|system check|how are things)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_system_health',
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'Checking system health...',
|
||||||
|
action: 'health_check',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── List/show ──
|
||||||
|
{
|
||||||
|
intent: 'list',
|
||||||
|
patterns: [
|
||||||
|
/\b(?:list|show|what.*running|what.*deployed|what.*have|what.*services)\b/i,
|
||||||
|
],
|
||||||
|
action: 'dashcaddy_list_services',
|
||||||
|
response: (msg) => ({
|
||||||
|
message: 'Here are your services:',
|
||||||
|
action: 'list_services',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Intent Router ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function routeIntent(message) {
|
||||||
|
const msg = message.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Try each intent pattern
|
||||||
|
for (const intent of INTENT_PATTERNS) {
|
||||||
|
for (const pattern of intent.patterns) {
|
||||||
|
if (pattern.test(message)) {
|
||||||
|
const result = {
|
||||||
|
intent: intent.intent,
|
||||||
|
confidence: 0.85,
|
||||||
|
action: intent.action,
|
||||||
|
message: message,
|
||||||
|
response: typeof intent.response === 'function' ? intent.response(message) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract app name for deploy intents
|
||||||
|
if (intent.extractApp) {
|
||||||
|
const app = intent.extractApp(message);
|
||||||
|
if (app) result.appId = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract service name for diagnose intents
|
||||||
|
if (intent.extractService) {
|
||||||
|
const service = intent.extractService(message);
|
||||||
|
if (service) result.serviceId = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest categories for recommend intents
|
||||||
|
if (intent.suggestCategories) {
|
||||||
|
result.categories = intent.suggestCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match — return a fallback that suggests using the catalog
|
||||||
|
return {
|
||||||
|
intent: 'unknown',
|
||||||
|
confidence: 0.3,
|
||||||
|
message,
|
||||||
|
response: {
|
||||||
|
message: 'I\'m not sure what you\'d like to do. Here are some things I can help with:',
|
||||||
|
suggestions: [
|
||||||
|
'Deploy an app: "Deploy Plex" or "Set up Nextcloud"',
|
||||||
|
'Get recommendations: "I want to stream movies" or "Block ads on my network"',
|
||||||
|
'Check status: "Is everything OK?" or "Why is Plex down?"',
|
||||||
|
'Browse catalog: "What can I self-host?"',
|
||||||
|
'Create backup: "Back up everything"',
|
||||||
|
],
|
||||||
|
action: 'suggest',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Express Route ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
module.exports = function({ asyncHandler }) {
|
||||||
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/ai/intent
|
||||||
|
*
|
||||||
|
* Natural language → structured action plan
|
||||||
|
*/
|
||||||
|
router.post('/ai/intent', wrap(async (req, res) => {
|
||||||
|
const { message, context = {} } = req.body || {};
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'string') {
|
||||||
|
return errorResponse(res, 400, 'message (string) is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = routeIntent(message);
|
||||||
|
|
||||||
|
// Add context from the request
|
||||||
|
result.context = context;
|
||||||
|
result.timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// For deploy intents with an appId, include the deploy plan
|
||||||
|
if (result.intent === 'deploy' && result.appId) {
|
||||||
|
result.deployPlan = {
|
||||||
|
templateId: result.appId,
|
||||||
|
endpoint: 'POST /api/v1/discover/adopt',
|
||||||
|
body: {
|
||||||
|
containerId: null, // Will be set after container creation
|
||||||
|
serviceId: result.appId,
|
||||||
|
name: result.appId.charAt(0).toUpperCase() + result.appId.slice(1),
|
||||||
|
port: null, // Will be set from template
|
||||||
|
generateDns: true,
|
||||||
|
generateRoute: true,
|
||||||
|
},
|
||||||
|
nextSteps: [
|
||||||
|
`Search catalog: GET /api/v1/catalog/search?q=${result.appId}`,
|
||||||
|
`Get template: GET /api/v1/catalog/${result.appId}`,
|
||||||
|
`Deploy: POST /api/v1/discover/adopt`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For recommend intents, include the wizard endpoint
|
||||||
|
if (result.intent === 'recommend' && result.categories) {
|
||||||
|
result.wizardCall = {
|
||||||
|
endpoint: 'POST /api/v1/wizard/recommend',
|
||||||
|
body: { categories: result.categories, hardwareProfile: 'medium' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, result);
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/ai/capabilities
|
||||||
|
* Returns what the AI can do — useful for agent self-discovery
|
||||||
|
*/
|
||||||
|
router.get('/ai/capabilities', wrap(async (req, res) => {
|
||||||
|
ok(res, {
|
||||||
|
intents: [...new Set(INTENT_PATTERNS.map(p => p.intent))],
|
||||||
|
capabilities: [
|
||||||
|
{ name: 'deploy', description: 'Deploy self-hosted applications from the catalog' },
|
||||||
|
{ name: 'recommend', description: 'Get service recommendations based on goals' },
|
||||||
|
{ name: 'diagnose', description: 'Troubleshoot service issues' },
|
||||||
|
{ name: 'backup', description: 'Create full system backups' },
|
||||||
|
{ name: 'health', description: 'Check system and service health' },
|
||||||
|
{ name: 'list', description: 'List services and containers' },
|
||||||
|
],
|
||||||
|
tools: '17 MCP tools available via MCP protocol at src/mcp/mcp-server.js',
|
||||||
|
exampleQueries: [
|
||||||
|
'Deploy Plex',
|
||||||
|
'I want to stream movies',
|
||||||
|
'Block ads on my network',
|
||||||
|
'Why is Plex down?',
|
||||||
|
'Back up everything',
|
||||||
|
'What services am I running?',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.routeIntent = routeIntent;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
|
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||||
|
router.get('/log-insights', asyncHandler(async (req, res) => {
|
||||||
|
const hours = parseInt(req.query.hours) || 24;
|
||||||
|
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// --- Collect data ---
|
||||||
|
const auditEntries = await auditLogger.query({ limit: 10000 });
|
||||||
|
const recentAudit = auditEntries.filter(e => e.timestamp >= since);
|
||||||
|
|
||||||
|
let securityEvents = [];
|
||||||
|
try { securityEvents = securityEventStore.query({ since, limit: 10000 }); } catch {}
|
||||||
|
|
||||||
|
// --- Analyze IPs ---
|
||||||
|
const ipMap = {};
|
||||||
|
recentAudit.forEach(e => {
|
||||||
|
const ip = e.ip || 'unknown';
|
||||||
|
if (!ipMap[ip]) ipMap[ip] = { count: 0, actions: {}, resources: new Set(), first: e.timestamp, last: e.timestamp, failures: 0 };
|
||||||
|
const s = ipMap[ip];
|
||||||
|
s.count++;
|
||||||
|
const cat = (e.action || 'unknown').split('.')[0];
|
||||||
|
s.actions[cat] = (s.actions[cat] || 0) + 1;
|
||||||
|
if (e.resource) s.resources.add(e.resource);
|
||||||
|
if (e.timestamp < s.first) s.first = e.timestamp;
|
||||||
|
if (e.timestamp > s.last) s.last = e.timestamp;
|
||||||
|
if (e.outcome === 'failure' || e.outcome === 'denied') s.failures++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Build plain-English insights ---
|
||||||
|
const insights = [];
|
||||||
|
const ipArray = Object.entries(ipMap).sort((a, b) => b[1].count - a[1].count);
|
||||||
|
|
||||||
|
// Heavy users
|
||||||
|
ipArray.slice(0, 3).forEach(([ip, s]) => {
|
||||||
|
const topAction = Object.entries(s.actions).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
insights.push({
|
||||||
|
severity: s.count > 500 ? 'warning' : 'info',
|
||||||
|
title: ip + ' — ' + s.count + ' requests in ' + hours + 'h',
|
||||||
|
plain: ip + ' made ' + s.count + ' requests (mostly ' + (topAction ? topAction[0] : 'unknown') + ')' +
|
||||||
|
(s.failures > 0 ? ', ' + s.failures + ' failed' : '') + '.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth failures
|
||||||
|
const totalFailures = recentAudit.filter(e => e.outcome === 'failure' || e.outcome === 'denied').length;
|
||||||
|
if (totalFailures > 5) {
|
||||||
|
insights.push({
|
||||||
|
severity: totalFailures > 50 ? 'warning' : 'info',
|
||||||
|
title: totalFailures + ' failed actions',
|
||||||
|
plain: totalFailures + ' requests were denied or failed in the last ' + hours + ' hours.' +
|
||||||
|
(totalFailures > 50 ? ' This could indicate someone trying to brute-force access.' : '')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security events
|
||||||
|
const secBySev = {};
|
||||||
|
securityEvents.forEach(e => { secBySev[e.severity] = (secBySev[e.severity] || 0) + 1; });
|
||||||
|
if (secBySev.critical || secBySev.error) {
|
||||||
|
insights.push({
|
||||||
|
severity: 'warning',
|
||||||
|
title: ((secBySev.critical || 0) + (secBySev.error || 0)) + ' security alerts',
|
||||||
|
plain: (secBySev.critical || 0) + ' critical and ' + (secBySev.error || 0) + ' error-level security events were logged.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet / nothing
|
||||||
|
if (insights.length === 0) {
|
||||||
|
insights.push({ severity: 'ok', title: 'All quiet', plain: 'No notable activity in the last ' + hours + ' hours.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage info ---
|
||||||
|
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||||
|
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||||
|
let storage = {};
|
||||||
|
try {
|
||||||
|
const a = await fs.stat(auditPath);
|
||||||
|
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const s = await fs.stat(secPath);
|
||||||
|
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
period: { hours, since, until: new Date().toISOString() },
|
||||||
|
summary: {
|
||||||
|
totalRequests: recentAudit.length,
|
||||||
|
uniqueIPs: ipArray.length,
|
||||||
|
securityEvents: securityEvents.length,
|
||||||
|
failedActions: totalFailures
|
||||||
|
},
|
||||||
|
topIPs: ipArray.slice(0, 10).map(([ip, s]) => ({
|
||||||
|
ip: ip,
|
||||||
|
count: s.count,
|
||||||
|
failures: s.failures,
|
||||||
|
topActions: Object.entries(s.actions).sort((a, b) => b[1] - a[1]).slice(0, 3),
|
||||||
|
activeFrom: s.first,
|
||||||
|
lastSeen: s.last
|
||||||
|
})),
|
||||||
|
insights: insights,
|
||||||
|
storage: storage
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||||
|
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
||||||
|
const keepDays = parseInt(req.body.keepDays) || 30;
|
||||||
|
const confirm = req.body.confirm === true;
|
||||||
|
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
||||||
|
|
||||||
|
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||||
|
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||||
|
|
||||||
|
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
||||||
|
const auditData = JSON.parse(auditRaw);
|
||||||
|
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
||||||
|
|
||||||
|
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
||||||
|
const secLines = secRaw.split('\n').filter(Boolean);
|
||||||
|
const oldSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp < cutoff; } catch (e) { return false; } });
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
ok(res, {
|
||||||
|
preview: true,
|
||||||
|
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
|
||||||
|
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
|
cutoffDate: cutoff
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute cleanup
|
||||||
|
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
||||||
|
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
||||||
|
|
||||||
|
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
||||||
|
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
disposed: true,
|
||||||
|
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||||
|
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
||||||
|
cutoffDate: cutoff
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -93,6 +93,7 @@ const eventsRoutes = require('../routes/events');
|
|||||||
const workflowsRoutes = require('../routes/workflows');
|
const workflowsRoutes = require('../routes/workflows');
|
||||||
const dependenciesRoutes = require('../routes/dependencies');
|
const dependenciesRoutes = require('../routes/dependencies');
|
||||||
const securityRoutes = require('../routes/security');
|
const securityRoutes = require('../routes/security');
|
||||||
|
const logInsightsRoutes = require('../routes/log-insights');
|
||||||
const billingRoutes = require('../routes/billing');
|
const billingRoutes = require('../routes/billing');
|
||||||
const DependencyManager = require('./managers/dependency-manager');
|
const DependencyManager = require('./managers/dependency-manager');
|
||||||
const autoRestartRoutes = require('../routes/auto-restart');
|
const autoRestartRoutes = require('../routes/auto-restart');
|
||||||
@@ -753,6 +754,20 @@ async function createApp() {
|
|||||||
apiRouter.use('/security', securityRoutes({
|
apiRouter.use('/security', securityRoutes({
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Log Insights — plain English activity summary + safe log disposal
|
||||||
|
apiRouter.use(logInsightsRoutes({
|
||||||
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
ok: ctx.ok,
|
||||||
|
auditLogger: ctx.auditLogger,
|
||||||
|
securityEventStore: (function() {
|
||||||
|
try {
|
||||||
|
var getStore = require('./security/event-store').getStore;
|
||||||
|
return getStore();
|
||||||
|
} catch (e) { return null; }
|
||||||
|
})()
|
||||||
|
}));
|
||||||
|
|
||||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||||
dependencyManager: ctx.dependencyManager,
|
dependencyManager: ctx.dependencyManager,
|
||||||
servicesStateManager: ctx.servicesStateManager,
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(platformPat
|
|||||||
const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(platformPaths.dataDir, 'container-stats-daily.json');
|
const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(platformPaths.dataDir, 'container-stats-daily.json');
|
||||||
const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(platformPaths.dataDir, 'alert-config.json');
|
const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(platformPaths.dataDir, 'alert-config.json');
|
||||||
const ALERT_HISTORY_FILE = process.env.ALERT_HISTORY_FILE || path.join(platformPaths.dataDir, 'alert-history.json');
|
const ALERT_HISTORY_FILE = process.env.ALERT_HISTORY_FILE || path.join(platformPaths.dataDir, 'alert-history.json');
|
||||||
|
const MAX_STATS_PER_CONTAINER = parseInt(process.env.STATS_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||||
const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw
|
const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw
|
||||||
const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly
|
const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly
|
||||||
const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily
|
const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily
|
||||||
@@ -242,6 +243,11 @@ class ResourceMonitor extends EventEmitter {
|
|||||||
containerStats.history = containerStats.history.filter(s =>
|
containerStats.history = containerStats.history.filter(s =>
|
||||||
new Date(s.timestamp).getTime() > cutoffTime
|
new Date(s.timestamp).getTime() > cutoffTime
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Also cap total entries per container (disk explosion fix)
|
||||||
|
if (containerStats.history.length > MAX_STATS_PER_CONTAINER) {
|
||||||
|
containerStats.history = containerStats.history.slice(-MAX_STATS_PER_CONTAINER);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -620,7 +626,7 @@ class ResourceMonitor extends EventEmitter {
|
|||||||
saveStats() {
|
saveStats() {
|
||||||
try {
|
try {
|
||||||
const data = Object.fromEntries(this.stats);
|
const data = Object.fromEntries(this.stats);
|
||||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
|
fs.writeFileSync(STATS_FILE, JSON.stringify(data)); // Compact JSON to reduce file size
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('monitor', error, { operation: 'saveStats' });
|
log.error('monitor', error, { operation: 'saveStats' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
/**
|
||||||
|
* DashCaddy MCP (Model Context Protocol) Server
|
||||||
|
*
|
||||||
|
* Makes DashCaddy controllable by ANY AI agent — Hermes, Claude, GPT, etc.
|
||||||
|
* The AI agent connects to this server and can:
|
||||||
|
* - List and manage services/containers
|
||||||
|
* - Deploy apps from the catalog
|
||||||
|
* - Manage DNS records and Caddyfile routes
|
||||||
|
* - Run diagnostics
|
||||||
|
* - Create backups and restore
|
||||||
|
* - Check system health
|
||||||
|
*
|
||||||
|
* Protocol: JSON-RPC 2.0 over stdio
|
||||||
|
* Spec: https://modelcontextprotocol.io
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node mcp-server.js
|
||||||
|
*
|
||||||
|
* In an AI agent config (e.g. Claude Desktop):
|
||||||
|
* {
|
||||||
|
* "mcpServers": {
|
||||||
|
* "dashcaddy": {
|
||||||
|
* "command": "node",
|
||||||
|
* "args": ["/path/to/mcp-server.js"],
|
||||||
|
* "env": {
|
||||||
|
* "DASHCADDY_URL": "http://localhost:3001",
|
||||||
|
* "DASHCADDY_API_KEY": "dk_..."
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BASE_URL = process.env.DASHCADDY_URL || 'http://localhost:3001';
|
||||||
|
const API_KEY = process.env.DASHCADDY_API_KEY || '';
|
||||||
|
const MCP_VERSION = '2024-11-05';
|
||||||
|
|
||||||
|
// ─── Tool Definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
// ── Services ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_list_services',
|
||||||
|
description: 'List all services on the DashCaddy dashboard. Returns service ID, name, status (up/down), URL, and health.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_get_service',
|
||||||
|
description: 'Get details for a specific service by ID. Includes health history, credentials, and configuration.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
serviceId: { type: 'string', description: 'The service ID (e.g. "plex")' },
|
||||||
|
},
|
||||||
|
required: ['serviceId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_check_health',
|
||||||
|
description: 'Check the health of all services or a specific service. Returns up/down status, response time, and HTTP status code.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
serviceId: { type: 'string', description: 'Optional: check only this service. Omit for all services.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── System ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_system_health',
|
||||||
|
description: 'Get overall system health summary. Returns status (healthy/degraded/unhealthy), service counts, memory, disk, and uptime. Great for "is everything OK?" queries.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_system_metrics',
|
||||||
|
description: 'Get Prometheus-format metrics for system monitoring. Includes request counts, error rates, memory gauges.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Containers ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_list_containers',
|
||||||
|
description: 'List all Docker containers (running and stopped). Returns container ID, name, image, status, and ports.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
all: { type: 'boolean', description: 'Include stopped containers (default: true)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_container_action',
|
||||||
|
description: 'Start, stop, restart, or remove a Docker container.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
containerId: { type: 'string', description: 'Container ID or name' },
|
||||||
|
action: { type: 'string', enum: ['start', 'stop', 'restart', 'remove'], description: 'Action to perform' },
|
||||||
|
},
|
||||||
|
required: ['containerId', 'action'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Catalog & Discovery ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_search_catalog',
|
||||||
|
description: 'Search the app catalog for self-hostable applications. Use this when a user asks "can DashCaddy host X?" or "I want to self-host Y".',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Search query (e.g. "media streaming", "password manager", "ad blocker")' },
|
||||||
|
category: { type: 'string', description: 'Filter by category (media, development, network, database, etc.)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_discover_services',
|
||||||
|
description: 'Auto-detect running Docker containers and suggest adding them to the dashboard. Returns discovered services with suggested configs.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Deployment ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_deploy_app',
|
||||||
|
description: 'Deploy a self-hosted application from the catalog. This is the main "self-host X" action. Pulls the Docker image, creates the container, generates a Caddyfile reverse proxy route, and adds the service to the dashboard. Returns the URL the user can access.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
templateId: { type: 'string', description: 'App template ID from the catalog (e.g. "plex", "gitea", "nextcloud")' },
|
||||||
|
subdomain: { type: 'string', description: 'Subdomain for the service (e.g. "plex" → plex.example.com)' },
|
||||||
|
port: { type: 'number', description: 'Override the default port' },
|
||||||
|
},
|
||||||
|
required: ['templateId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_wizard_recommend',
|
||||||
|
description: 'Get service recommendations based on what the user wants to self-host. Use this when a user describes a goal (e.g. "I want to stream movies" → recommends Plex, Sonarr, Radarr).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
categories: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Categories: media-streaming, file-sync, home-network, smart-home, development, monitoring',
|
||||||
|
},
|
||||||
|
hardwareProfile: { type: 'string', enum: ['minimal', 'medium', 'powerful'], description: 'Hardware capability (default: medium)' },
|
||||||
|
},
|
||||||
|
required: ['categories'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── DNS & Proxy ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_list_dns',
|
||||||
|
description: 'List DNS records. Useful for "what domains point to this server?"',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
zone: { type: 'string', description: 'DNS zone to query (optional)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_generate_caddyfile',
|
||||||
|
description: 'Generate a Caddyfile reverse proxy block from structured config. Useful for setting up custom reverse proxy rules.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain: { type: 'string', description: 'Domain name (e.g. "app.example.com")' },
|
||||||
|
upstream: { type: 'string', description: 'Upstream address (e.g. "localhost:8080")' },
|
||||||
|
websocket: { type: 'boolean', description: 'Enable WebSocket support' },
|
||||||
|
cors: { type: 'boolean', description: 'Enable CORS headers' },
|
||||||
|
auth: { type: 'boolean', description: 'Enable DashCaddy SSO auth gate' },
|
||||||
|
},
|
||||||
|
required: ['domain', 'upstream'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Diagnostics ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_diagnose',
|
||||||
|
description: 'Run diagnostics on a service or the entire system. Checks container logs, resource usage, network connectivity, and health endpoints. Returns structured findings with severity levels.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
serviceId: { type: 'string', description: 'Service to diagnose (omit for system-wide)' },
|
||||||
|
depth: { type: 'string', enum: ['quick', 'standard', 'deep'], description: 'Diagnostic depth (default: standard)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Backup & Recovery ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_create_backup',
|
||||||
|
description: 'Create a full system backup (services, config, credentials, Caddyfile, themes). Returns the backup data.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_get_backup_status',
|
||||||
|
description: 'Check the status of the last backup and restore operations.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Fleet ──
|
||||||
|
{
|
||||||
|
name: 'dashcaddy_list_fleet',
|
||||||
|
description: 'List all hosts in the DashCaddy fleet (for multi-server management).',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── API Client ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function apiCall(method, path, body) {
|
||||||
|
const url = `${BASE_URL}/api/v1${path}`;
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (API_KEY) headers['x-api-key'] = API_KEY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch { data = { raw: text }; }
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
status: response.status,
|
||||||
|
message: data.error || data.message || `HTTP ${response.status}`,
|
||||||
|
code: data.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
return { error: true, message: err.message, code: 'NETWORK_ERROR' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleTool(name, args) {
|
||||||
|
switch (name) {
|
||||||
|
// ── Services ──
|
||||||
|
case 'dashcaddy_list_services': {
|
||||||
|
const data = await apiCall('GET', '/services');
|
||||||
|
if (data.error) return data;
|
||||||
|
const services = data.services || data.data || [];
|
||||||
|
return {
|
||||||
|
count: services.length,
|
||||||
|
services: services.map(s => ({
|
||||||
|
id: s.id, name: s.name, status: s.status || 'unknown',
|
||||||
|
url: s.url, subdomain: s.subdomain, type: s.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_get_service': {
|
||||||
|
return apiCall('GET', `/services/${args.serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_check_health': {
|
||||||
|
if (args.serviceId) {
|
||||||
|
return apiCall('GET', `/services/${args.serviceId}/health`);
|
||||||
|
}
|
||||||
|
return apiCall('GET', '/health/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System ──
|
||||||
|
case 'dashcaddy_system_health': {
|
||||||
|
// Public endpoint — no auth needed
|
||||||
|
const response = await fetch(`${BASE_URL}/api/v1/system/health`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_system_metrics': {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/v1/metrics/prometheus`);
|
||||||
|
return { metrics: await response.text() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Containers ──
|
||||||
|
case 'dashcaddy_list_containers': {
|
||||||
|
const all = args.all !== false;
|
||||||
|
return apiCall('GET', `/containers?all=${all}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_container_action': {
|
||||||
|
const { containerId, action } = args;
|
||||||
|
const method = action === 'remove' ? 'DELETE' : 'POST';
|
||||||
|
return apiCall(method, `/containers/${containerId}/${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Catalog & Discovery ──
|
||||||
|
case 'dashcaddy_search_catalog': {
|
||||||
|
let path = '/catalog';
|
||||||
|
if (args.query) {
|
||||||
|
return apiCall('GET', `/catalog/search?q=${encodeURIComponent(args.query)}`);
|
||||||
|
}
|
||||||
|
if (args.category) path += `?category=${args.category}`;
|
||||||
|
return apiCall('GET', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_discover_services': {
|
||||||
|
return apiCall('GET', '/discover');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deployment ──
|
||||||
|
case 'dashcaddy_deploy_app': {
|
||||||
|
// Step 1: Get template details
|
||||||
|
const template = await apiCall('GET', `/catalog/${args.templateId}`);
|
||||||
|
if (template.error) return template;
|
||||||
|
|
||||||
|
// Step 2: Generate Caddyfile route
|
||||||
|
const port = args.port || template.ports?.[0] || 8080;
|
||||||
|
const subdomain = args.subdomain || args.templateId;
|
||||||
|
const caddy = await apiCall('POST', '/caddycode/generate', {
|
||||||
|
domain: `${subdomain}.sami`,
|
||||||
|
upstream: `localhost:${port}`,
|
||||||
|
websocket: true,
|
||||||
|
cors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Create service entry
|
||||||
|
const service = await apiCall('POST', '/services', {
|
||||||
|
id: subdomain,
|
||||||
|
name: template.name,
|
||||||
|
subdomain,
|
||||||
|
domain: `${subdomain}.sami`,
|
||||||
|
url: `https://${subdomain}.sami`,
|
||||||
|
port,
|
||||||
|
protocol: 'http',
|
||||||
|
type: template.category || 'generic',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deployed: !service.error,
|
||||||
|
service: service.error ? null : service,
|
||||||
|
caddyfile: caddy.error ? null : caddy.caddyfile,
|
||||||
|
url: `https://${subdomain}.sami`,
|
||||||
|
message: service.error
|
||||||
|
? `Deployment failed: ${service.message}`
|
||||||
|
: `${template.name} deployed! Access it at https://${subdomain}.sami`,
|
||||||
|
nextSteps: [
|
||||||
|
`Pull the Docker image: docker pull ${template.image || 'unknown'}`,
|
||||||
|
`Run the container with port ${port} mapped`,
|
||||||
|
`The Caddyfile route is configured — the URL should work once the container is running`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_wizard_recommend': {
|
||||||
|
return apiCall('POST', '/wizard/recommend', {
|
||||||
|
categories: args.categories,
|
||||||
|
hardwareProfile: args.hardwareProfile || 'medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DNS & Proxy ──
|
||||||
|
case 'dashcaddy_list_dns': {
|
||||||
|
let path = '/dns';
|
||||||
|
if (args.zone) path += `?zone=${args.zone}`;
|
||||||
|
return apiCall('GET', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_generate_caddyfile': {
|
||||||
|
return apiCall('POST', '/caddycode/generate', {
|
||||||
|
domain: args.domain,
|
||||||
|
upstream: args.upstream,
|
||||||
|
websocket: args.websocket,
|
||||||
|
cors: args.cors,
|
||||||
|
auth: args.auth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Diagnostics ──
|
||||||
|
case 'dashcaddy_diagnose': {
|
||||||
|
const findings = [];
|
||||||
|
|
||||||
|
if (args.serviceId) {
|
||||||
|
// Service-specific diagnosis
|
||||||
|
const health = await apiCall('GET', `/services/${args.serviceId}/health`);
|
||||||
|
if (health.error) {
|
||||||
|
findings.push({ severity: 'critical', message: `Cannot reach service: ${health.message}` });
|
||||||
|
} else {
|
||||||
|
findings.push({ severity: 'info', message: `Service ${args.serviceId} health: ${JSON.stringify(health)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System-wide checks
|
||||||
|
const sysHealth = await apiCall('GET', '/system/health');
|
||||||
|
if (!sysHealth.error) {
|
||||||
|
findings.push({ severity: sysHealth.status === 'healthy' ? 'ok' : 'warning',
|
||||||
|
message: `System status: ${sysHealth.status}, services: ${JSON.stringify(sysHealth.checks?.services)}` });
|
||||||
|
|
||||||
|
if (sysHealth.checks?.memory?.percentage > 85) {
|
||||||
|
findings.push({ severity: 'warning', message: `High memory usage: ${sysHealth.checks.memory.percentage}%` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { findings, depth: args.depth || 'standard' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backup & Recovery ──
|
||||||
|
case 'dashcaddy_create_backup': {
|
||||||
|
return apiCall('POST', '/disaster/backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'dashcaddy_get_backup_status': {
|
||||||
|
return apiCall('GET', '/disaster/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fleet ──
|
||||||
|
case 'dashcaddy_list_fleet': {
|
||||||
|
return apiCall('GET', '/fleet/hosts');
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { error: true, message: `Unknown tool: ${name}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MCP Protocol Handler ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleMessage(msg) {
|
||||||
|
const { id, method, params } = msg;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'initialize': {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: MCP_VERSION,
|
||||||
|
serverInfo: {
|
||||||
|
name: 'dashcaddy',
|
||||||
|
version: '1.15.0',
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
tools: { listChanged: false },
|
||||||
|
resources: { listChanged: false, subscribe: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tools/list': {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: { tools: TOOLS },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tools/call': {
|
||||||
|
const { name, arguments: args } = params;
|
||||||
|
return handleTool(name, args).then(result => ({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})).catch(err => ({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: { code: -32603, message: err.message },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'resources/list': {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
resources: [
|
||||||
|
{ uri: 'dashcaddy://services', name: 'Services', description: 'All DashCaddy services' },
|
||||||
|
{ uri: 'dashcaddy://health', name: 'System Health', description: 'Current system health status' },
|
||||||
|
{ uri: 'dashcaddy://catalog', name: 'App Catalog', description: 'Available self-hostable apps' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ping': {
|
||||||
|
return { jsonrpc: '2.0', id, result: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
if (id) {
|
||||||
|
return {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
error: { code: -32601, message: `Method not found: ${method}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Notification — no response needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stdio Transport ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
||||||
|
|
||||||
|
process.stderr.write(`[DashCaddy MCP] Server starting — connecting to ${BASE_URL}\n`);
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
process.stderr.write(`[DashCaddy MCP] Invalid JSON: ${line.substring(0, 100)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = handleMessage(msg);
|
||||||
|
|
||||||
|
if (response && typeof response.then === 'function') {
|
||||||
|
// Async handler
|
||||||
|
response.then(res => {
|
||||||
|
if (res) process.stdout.write(JSON.stringify(res) + '\n');
|
||||||
|
}).catch(err => {
|
||||||
|
process.stderr.write(`[DashCaddy MCP] Error: ${err.message}\n`);
|
||||||
|
});
|
||||||
|
} else if (response) {
|
||||||
|
// Sync handler
|
||||||
|
process.stdout.write(JSON.stringify(response) + '\n');
|
||||||
|
}
|
||||||
|
// Notifications (no id) get no response
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
process.stderr.write('[DashCaddy MCP] Server shutting down\n');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
|
|||||||
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
||||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||||
|
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||||
|
|
||||||
class HealthChecker extends EventEmitter {
|
class HealthChecker extends EventEmitter {
|
||||||
@@ -217,7 +218,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
message: healthy ? 'Service is healthy' : 'Service check failed',
|
message: healthy ? 'Service is healthy' : 'Service check failed',
|
||||||
details: {
|
details: {
|
||||||
headers: res.headers,
|
headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix
|
||||||
bodyLength: data.length
|
bodyLength: data.length
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -286,6 +287,11 @@ class HealthChecker extends EventEmitter {
|
|||||||
|
|
||||||
this.history[serviceId].push(status);
|
this.history[serviceId].push(status);
|
||||||
|
|
||||||
|
// Cap entries to prevent unbounded growth (disk explosion fix)
|
||||||
|
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||||
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
|
}
|
||||||
|
|
||||||
// Emit status event
|
// Emit status event
|
||||||
this.emit('status-check', status);
|
this.emit('status-check', status);
|
||||||
|
|
||||||
@@ -565,6 +571,10 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.history[serviceId] = this.history[serviceId].filter(h =>
|
this.history[serviceId] = this.history[serviceId].filter(h =>
|
||||||
new Date(h.timestamp).getTime() > cutoffTime
|
new Date(h.timestamp).getTime() > cutoffTime
|
||||||
);
|
);
|
||||||
|
// Also cap total entries per service
|
||||||
|
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
|
||||||
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +626,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
saveHistory() {
|
saveHistory() {
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history)); // Compact JSON (no pretty-print) to reduce file size
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ dist/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
LOGO_INTEGRATION.md
|
LOGO_INTEGRATION.md
|
||||||
README-TESTER.txt
|
README-TESTER.txt
|
||||||
|
build-output
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ---- Constants -------------------------------------------------------------
|
# ---- Constants -------------------------------------------------------------
|
||||||
readonly DASHCADDY_VERSION="1.14.6"
|
readonly DASHCADDY_VERSION="1.15.0"
|
||||||
readonly DASHCADDY_DOWNLOAD="https://get.dashcaddy.net/release/latest.tar.gz"
|
readonly DASHCADDY_DOWNLOAD="https://get.dashcaddy.net/release/latest.tar.gz"
|
||||||
readonly DASHCADDY_REPO="" # Set to a git URL to clone instead of downloading
|
readonly DASHCADDY_REPO="" # Set to a git URL to clone instead of downloading
|
||||||
readonly INSTALL_DIR="/etc/dashcaddy"
|
readonly INSTALL_DIR="/etc/dashcaddy"
|
||||||
@@ -35,6 +35,7 @@ API_PORT=3001
|
|||||||
LOCAL_PORT=8080
|
LOCAL_PORT=8080
|
||||||
BACKUP_DIR=""
|
BACKUP_DIR=""
|
||||||
BACKUP_LIMIT=""
|
BACKUP_LIMIT=""
|
||||||
|
DISK_SIZE=""
|
||||||
|
|
||||||
# ---- Runtime state ---------------------------------------------------------
|
# ---- Runtime state ---------------------------------------------------------
|
||||||
DOMAIN_MODE="" # public | custom-tld | local
|
DOMAIN_MODE="" # public | custom-tld | local
|
||||||
@@ -386,6 +387,92 @@ EOF
|
|||||||
mkdir -p /etc/caddy
|
mkdir -p /etc/caddy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VM Disk Sandbox — bounded virtual disk for DashCaddy data
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
create_disk_sandbox() {
|
||||||
|
[[ -z "$DISK_SIZE" ]] && return 0
|
||||||
|
|
||||||
|
local size_bytes
|
||||||
|
size_bytes=$(parse_size_to_bytes "$DISK_SIZE")
|
||||||
|
local size_gb=$(( size_bytes / 1073741824 ))
|
||||||
|
|
||||||
|
log "Creating ${size_gb}GB virtual disk sandbox..."
|
||||||
|
|
||||||
|
local image_path="/opt/dashcaddy-data.raw"
|
||||||
|
local mount_point="/opt/dashcaddy-data"
|
||||||
|
|
||||||
|
# Check available disk space (need size + 2GB buffer)
|
||||||
|
local avail_kb
|
||||||
|
avail_kb=$(df --output=avail / | tail -1 | tr -d ' ')
|
||||||
|
local avail_gb=$(( avail_kb / 1048576 ))
|
||||||
|
if (( avail_gb < size_gb + 2 )); then
|
||||||
|
fatal "Not enough disk space: ${avail_gb}GB free, need ${size_gb}GB + 2GB buffer"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create sparse image (instant — only grows as data fills)
|
||||||
|
progress "Creating ${size_gb}GB sparse disk image" truncate -s "${size_gb}G" "$image_path"
|
||||||
|
|
||||||
|
# Format as ext4
|
||||||
|
progress "Formatting ext4 filesystem" mkfs.ext4 -F -L dashcaddy "$image_path"
|
||||||
|
|
||||||
|
# Mount
|
||||||
|
mkdir -p "$mount_point"
|
||||||
|
progress "Mounting virtual disk" mount -o loop "$image_path" "$mount_point"
|
||||||
|
|
||||||
|
# Add to fstab for reboot persistence
|
||||||
|
if ! grep -q "$image_path" /etc/fstab 2>/dev/null; then
|
||||||
|
echo "${image_path} ${mount_point} ext4 loop,defaults 0 0" >> /etc/fstab
|
||||||
|
ok "Added to /etc/fstab (survives reboot)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Redirect Docker data-root into the sandbox
|
||||||
|
mkdir -p "${mount_point}/docker"
|
||||||
|
mkdir -p /etc/docker
|
||||||
|
local daemon_json="/etc/docker/daemon.json"
|
||||||
|
if [[ ! -f "$daemon_json" ]]; then
|
||||||
|
echo '{"data-root":"'"${mount_point}"'/docker"}' > "$daemon_json"
|
||||||
|
else
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
with open('${daemon_json}') as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
cfg['data-root'] = '${mount_point}/docker'
|
||||||
|
with open('${daemon_json}', 'w') as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
" 2>/dev/null || warn "Could not merge daemon.json — Docker may need manual data-root config"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restart Docker to pick up new data-root
|
||||||
|
if systemctl is-active --quiet docker 2>/dev/null; then
|
||||||
|
progress "Restarting Docker with new data-root" systemctl restart docker
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Redirect DashCaddy data dirs into the sandbox
|
||||||
|
mkdir -p "${mount_point}/dashcaddy-data"
|
||||||
|
ln -sf "${mount_point}/dashcaddy-data" "${INSTALL_DIR}/data-sandbox"
|
||||||
|
|
||||||
|
ok "Virtual disk sandbox active: ${size_gb}GB at ${mount_point}"
|
||||||
|
log "DashCaddy is now physically limited to ${size_gb}GB. No overflow possible."
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy_disk_sandbox() {
|
||||||
|
local image_path="/opt/dashcaddy-data.raw"
|
||||||
|
local mount_point="/opt/dashcaddy-data"
|
||||||
|
|
||||||
|
if mountpoint -q "$mount_point" 2>/dev/null; then
|
||||||
|
umount "$mount_point" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$image_path" ]]; then
|
||||||
|
rm -f "$image_path"
|
||||||
|
sed -i "\#${image_path}#d" /etc/fstab 2>/dev/null || true
|
||||||
|
ok "Virtual disk removed — all sandboxed data deleted"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Directory & File Setup
|
# Directory & File Setup
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -709,9 +796,17 @@ services:
|
|||||||
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
||||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||||
|
# --- Disk Safety (defense-in-depth inside the sandbox) ---
|
||||||
|
- HEALTH_HISTORY_RETENTION=14
|
||||||
|
- HEALTH_MAX_ENTRIES=500
|
||||||
|
- HEALTH_CHECK_INTERVAL=30000
|
||||||
|
- CONTAINER_STATS_MAX_ENTRIES=2000
|
||||||
|
- AUDIT_MAX_ENTRIES=1000
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: 1024m
|
||||||
|
memswap_limit: 2048m
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
@@ -835,22 +930,6 @@ start_caddy() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# DC-037: Make API source reachable from both the install path
|
|
||||||
# (${SITES_DIR}/dashcaddy-api, where this installer writes files) and the
|
|
||||||
# /opt/dashcaddy/dashcaddy-api path that the auto-updater and several runtime
|
|
||||||
# helpers default to. Without this, a first auto-update lands on a fresh host
|
|
||||||
# that wrote its API files to ${SITES_DIR}/dashcaddy-api but tried to read
|
|
||||||
# from /opt/dashcaddy/dashcaddy-api and crashes with
|
|
||||||
# `cp: cannot create directory '/etc/dashcaddy/sites/dashcaddy-api/routes':
|
|
||||||
# No such file or directory` because the trailing parent path is missing.
|
|
||||||
# `ln -sfn` is idempotent (safe on re-runs; does not fail if the link already
|
|
||||||
# points to the same target) and replaces any stale link.
|
|
||||||
install_api_symlink() {
|
|
||||||
mkdir -p /opt/dashcaddy
|
|
||||||
ln -sfn "${API_DIR}" /opt/dashcaddy/dashcaddy-api
|
|
||||||
ok "API symlink: /opt/dashcaddy/dashcaddy-api -> ${API_DIR}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Firewall
|
# Firewall
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -916,6 +995,8 @@ do_uninstall() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if $KEEP_CONFIG; then
|
if $KEEP_CONFIG; then
|
||||||
|
destroy_disk_sandbox
|
||||||
|
|
||||||
rm -rf "$API_DIR" "$DASHBOARD_DIR"
|
rm -rf "$API_DIR" "$DASHBOARD_DIR"
|
||||||
ok "App files removed, config preserved in ${INSTALL_DIR}/"
|
ok "App files removed, config preserved in ${INSTALL_DIR}/"
|
||||||
else
|
else
|
||||||
@@ -950,6 +1031,7 @@ parse_args() {
|
|||||||
--keep-config) KEEP_CONFIG=true; shift ;;
|
--keep-config) KEEP_CONFIG=true; shift ;;
|
||||||
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
||||||
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
||||||
|
--disk-size) DISK_SIZE="${2:-}"; shift; shift ;;
|
||||||
--yes|-y) AUTO_YES=true; shift ;;
|
--yes|-y) AUTO_YES=true; shift ;;
|
||||||
--help|-h) print_help; exit 0 ;;
|
--help|-h) print_help; exit 0 ;;
|
||||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||||
@@ -986,6 +1068,8 @@ print_help() {
|
|||||||
--skip-caddy Already have Caddy
|
--skip-caddy Already have Caddy
|
||||||
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
||||||
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
|
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
|
||||||
|
--disk-size SIZE Create a bounded virtual disk (e.g., 30GB, 100GB).
|
||||||
|
DashCaddy is sandboxed inside it and can NEVER exceed it.
|
||||||
--uninstall Remove DashCaddy
|
--uninstall Remove DashCaddy
|
||||||
--keep-config Keep configs during uninstall
|
--keep-config Keep configs during uninstall
|
||||||
--yes Skip confirmations
|
--yes Skip confirmations
|
||||||
@@ -1030,6 +1114,7 @@ print_success() {
|
|||||||
[[ -n "$lan_url" ]] && echo -e " ${BOLD}LAN access:${NC} ${lan_url}"
|
[[ -n "$lan_url" ]] && echo -e " ${BOLD}LAN access:${NC} ${lan_url}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${DIM}Config: ${INSTALL_DIR}/ | Logs: docker logs dashcaddy-api${NC}"
|
echo -e " ${DIM}Config: ${INSTALL_DIR}/ | Logs: docker logs dashcaddy-api${NC}"
|
||||||
|
[[ -n "$DISK_SIZE" ]] && echo -e " ${CYAN}Sandbox: ${DISK_SIZE} virtual disk active — data physically bounded${NC}"
|
||||||
echo -e " ${DIM}Installed in: ${total_time}${NC}"
|
echo -e " ${DIM}Installed in: ${total_time}${NC}"
|
||||||
|
|
||||||
if [[ "$DOMAIN_MODE" == "public" ]]; then
|
if [[ "$DOMAIN_MODE" == "public" ]]; then
|
||||||
@@ -1089,6 +1174,8 @@ main() {
|
|||||||
|
|
||||||
# ---- Step 4: Deploy files ----
|
# ---- Step 4: Deploy files ----
|
||||||
step "Deploying DashCaddy"
|
step "Deploying DashCaddy"
|
||||||
|
create_disk_sandbox
|
||||||
|
|
||||||
create_directories
|
create_directories
|
||||||
fetch_source
|
fetch_source
|
||||||
create_seed_configs
|
create_seed_configs
|
||||||
@@ -1107,7 +1194,6 @@ main() {
|
|||||||
# ---- Step 7: Start Caddy ----
|
# ---- Step 7: Start Caddy ----
|
||||||
step "Starting web server"
|
step "Starting web server"
|
||||||
start_caddy
|
start_caddy
|
||||||
install_api_symlink
|
|
||||||
|
|
||||||
print_success "$(elapsed "$start_time")"
|
print_success "$(elapsed "$start_time")"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1732,7 +1731,6 @@
|
|||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -1924,6 +1922,7 @@
|
|||||||
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^2.1.0",
|
"archiver-utils": "^2.1.0",
|
||||||
"async": "^3.2.4",
|
"async": "^3.2.4",
|
||||||
@@ -1943,6 +1942,7 @@
|
|||||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.4",
|
"glob": "^7.1.4",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
@@ -1965,6 +1965,7 @@
|
|||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
@@ -1980,7 +1981,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -1988,6 +1990,7 @@
|
|||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@@ -2215,6 +2218,7 @@
|
|||||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer": "^5.5.0",
|
"buffer": "^5.5.0",
|
||||||
"inherits": "^2.0.4",
|
"inherits": "^2.0.4",
|
||||||
@@ -2290,7 +2294,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2721,6 +2724,7 @@
|
|||||||
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-crc32": "^0.2.13",
|
"buffer-crc32": "^0.2.13",
|
||||||
"crc32-stream": "^4.0.2",
|
"crc32-stream": "^4.0.2",
|
||||||
@@ -2827,6 +2831,7 @@
|
|||||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"crc32": "bin/crc32.njs"
|
"crc32": "bin/crc32.njs"
|
||||||
},
|
},
|
||||||
@@ -2840,6 +2845,7 @@
|
|||||||
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crc-32": "^1.2.0",
|
"crc-32": "^1.2.0",
|
||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
@@ -3084,7 +3090,6 @@
|
|||||||
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
|
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "24.13.3",
|
"app-builder-lib": "24.13.3",
|
||||||
"builder-util": "24.13.1",
|
"builder-util": "24.13.1",
|
||||||
@@ -3269,6 +3274,7 @@
|
|||||||
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
|
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"app-builder-lib": "24.13.3",
|
"app-builder-lib": "24.13.3",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
@@ -3282,6 +3288,7 @@
|
|||||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
"jsonfile": "^6.0.1",
|
"jsonfile": "^6.0.1",
|
||||||
@@ -3297,6 +3304,7 @@
|
|||||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"universalify": "^2.0.0"
|
"universalify": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -3310,6 +3318,7 @@
|
|||||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
@@ -3799,7 +3808,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
@@ -4433,7 +4443,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/isbinaryfile": {
|
"node_modules/isbinaryfile": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.7",
|
||||||
@@ -5298,6 +5309,7 @@
|
|||||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readable-stream": "^2.0.5"
|
"readable-stream": "^2.0.5"
|
||||||
},
|
},
|
||||||
@@ -5311,6 +5323,7 @@
|
|||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
@@ -5326,7 +5339,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lazystream/node_modules/string_decoder": {
|
"node_modules/lazystream/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -5334,6 +5348,7 @@
|
|||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@@ -5380,35 +5395,40 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.difference": {
|
"node_modules/lodash.difference": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||||
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.flatten": {
|
"node_modules/lodash.flatten": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isplainobject": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.union": {
|
"node_modules/lodash.union": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||||
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lowercase-keys": {
|
"node_modules/lowercase-keys": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -6005,7 +6025,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/progress": {
|
"node_modules/progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
@@ -6127,6 +6148,7 @@
|
|||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"string_decoder": "^1.1.1",
|
"string_decoder": "^1.1.1",
|
||||||
@@ -6142,6 +6164,7 @@
|
|||||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^5.1.0"
|
"minimatch": "^5.1.0"
|
||||||
}
|
}
|
||||||
@@ -6278,7 +6301,8 @@
|
|||||||
"url": "https://feross.org/support"
|
"url": "https://feross.org/support"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
@@ -6525,6 +6549,7 @@
|
|||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
@@ -6698,6 +6723,7 @@
|
|||||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bl": "^4.0.3",
|
"bl": "^4.0.3",
|
||||||
"end-of-stream": "^1.4.1",
|
"end-of-stream": "^1.4.1",
|
||||||
@@ -6950,7 +6976,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/v8-to-istanbul": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
@@ -7151,6 +7178,7 @@
|
|||||||
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^3.0.4",
|
"archiver-utils": "^3.0.4",
|
||||||
"compress-commons": "^4.1.2",
|
"compress-commons": "^4.1.2",
|
||||||
@@ -7166,6 +7194,7 @@
|
|||||||
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.2.3",
|
"glob": "^7.2.3",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const { DEFAULT_PORTS } = require('../shared/constants');
|
|||||||
*
|
*
|
||||||
* Generates production-grade configs that match the patterns used by the
|
* Generates production-grade configs that match the patterns used by the
|
||||||
* running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.)
|
* running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.)
|
||||||
|
*
|
||||||
|
* DISK SAFETY: All generated configs include sensible defaults for storage
|
||||||
|
* limits — health retention, stats caps, and memory limits — so a fresh
|
||||||
|
* install will never silently fill a user's disk.
|
||||||
*/
|
*/
|
||||||
class CaddyfileGenerator {
|
class CaddyfileGenerator {
|
||||||
/**
|
/**
|
||||||
@@ -279,19 +283,43 @@ class CaddyfileGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate docker-compose.yml for running the API server
|
* Generate docker-compose.yml for running the API server.
|
||||||
|
*
|
||||||
|
* DISK SAFETY: Includes env vars for health retention, stats caps, and
|
||||||
|
* memory limits derived from the disk budget the user selected during
|
||||||
|
* install. These prevent the disk-explosion bugs seen in early versions.
|
||||||
|
*
|
||||||
* @param {string} installPath - Installation directory
|
* @param {string} installPath - Installation directory
|
||||||
* @param {Object} options - Configuration options
|
* @param {Object} options - Configuration options
|
||||||
* @param {number} options.apiPort - API server port
|
* @param {number} options.apiPort - API server port
|
||||||
* @param {string} options.lanIP - Host LAN IP address
|
* @param {string} options.lanIP - Host LAN IP address
|
||||||
* @param {string} options.tailscaleIP - Host Tailscale IP address
|
* @param {string} options.tailscaleIP - Host Tailscale IP address
|
||||||
* @param {string} options.domainMode - Domain mode (local, public, custom-tld)
|
* @param {string} options.domainMode - Domain mode (local, public, custom-tld)
|
||||||
|
* @param {Object} [options.disk] - Disk budget settings
|
||||||
|
* @param {number} [options.disk.healthRetentionDays=14] - Health history retention
|
||||||
|
* @param {number} [options.disk.healthMaxEntries=500] - Max health entries per service
|
||||||
|
* @param {number} [options.disk.healthCheckInterval=30000] - Health check interval (ms)
|
||||||
|
* @param {number} [options.disk.statsMaxEntries=2000] - Max container stats entries
|
||||||
|
* @param {number} [options.disk.auditMaxEntries=1000] - Max audit log entries
|
||||||
|
* @param {number} [options.disk.backupLimitGB=10] - Backup storage limit
|
||||||
|
* @param {string} [options.dockerDataPath] - Docker data root override
|
||||||
|
* @param {number} [options.memoryLimitMB=1024] - Container memory limit
|
||||||
*/
|
*/
|
||||||
generateDockerCompose(installPath, options = {}) {
|
generateDockerCompose(installPath, options = {}) {
|
||||||
const apiPort = options.apiPort || DEFAULT_PORTS.API;
|
const apiPort = options.apiPort || DEFAULT_PORTS.API;
|
||||||
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
|
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
|
||||||
const p = this._p.bind(this);
|
const p = this._p.bind(this);
|
||||||
|
|
||||||
|
// Disk budget settings with safe defaults
|
||||||
|
const disk = options.disk || {};
|
||||||
|
const healthRetentionDays = disk.healthRetentionDays || 14;
|
||||||
|
const healthMaxEntries = disk.healthMaxEntries || 500;
|
||||||
|
const healthCheckInterval = disk.healthCheckInterval || 30000;
|
||||||
|
const statsMaxEntries = disk.statsMaxEntries || 2000;
|
||||||
|
const auditMaxEntries = disk.auditMaxEntries || 1000;
|
||||||
|
const backupLimitGB = disk.backupLimitGB || 10;
|
||||||
|
const memoryLimitMB = options.memoryLimitMB || 1024;
|
||||||
|
|
||||||
// Core volume mounts
|
// Core volume mounts
|
||||||
let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw
|
let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw
|
||||||
- ${p(installPath)}/services.json:/app/services.json:rw
|
- ${p(installPath)}/services.json:/app/services.json:rw
|
||||||
@@ -308,12 +336,19 @@ class CaddyfileGenerator {
|
|||||||
volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`;
|
volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variables
|
// Environment variables — disk safety baked in
|
||||||
let envVars = ` - CADDYFILE_PATH=/caddyfile
|
let envVars = ` - CADDYFILE_PATH=/caddyfile
|
||||||
- CADDY_ADMIN_URL=http://host.docker.internal:${adminPort}
|
- CADDY_ADMIN_URL=http://host.docker.internal:${adminPort}
|
||||||
- ASSETS_PATH=/app/assets
|
- ASSETS_PATH=/app/assets
|
||||||
- CREDENTIALS_FILE=/app/credentials.json
|
- CREDENTIALS_FILE=/app/credentials.json
|
||||||
- NODE_ENV=production`;
|
- NODE_ENV=production
|
||||||
|
# --- Disk Safety ---
|
||||||
|
- HEALTH_HISTORY_RETENTION=${healthRetentionDays}
|
||||||
|
- HEALTH_MAX_ENTRIES=${healthMaxEntries}
|
||||||
|
- HEALTH_CHECK_INTERVAL=${healthCheckInterval}
|
||||||
|
- CONTAINER_STATS_MAX_ENTRIES=${statsMaxEntries}
|
||||||
|
- AUDIT_MAX_ENTRIES=${auditMaxEntries}
|
||||||
|
- BACKUP_MAX_STORAGE_BYTES=${backupLimitGB * 1024 * 1024 * 1024}`;
|
||||||
|
|
||||||
if (options.domainMode === 'custom-tld') {
|
if (options.domainMode === 'custom-tld') {
|
||||||
envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`;
|
envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`;
|
||||||
@@ -339,6 +374,9 @@ ${envVars}
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Memory limit prevents OOM during startup when all managers init
|
||||||
|
mem_limit: ${memoryLimitMB}m
|
||||||
|
memswap_limit: ${(memoryLimitMB * 2)}m
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return dockerCompose;
|
return dockerCompose;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ process.on('uncaughtException', (error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
|
const { registerVMHandlers } = require('./vm-ipc');
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -47,9 +48,23 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// App lifecycle handlers
|
// App lifecycle handlers
|
||||||
|
// --- Disk space check (for VM disk budget step) ---
|
||||||
|
ipcMain.handle('get-disk-space', async (event, targetPath) => {
|
||||||
|
try {
|
||||||
|
const stats = await require('fs').promises.statfs(targetPath || '/');
|
||||||
|
return {
|
||||||
|
free: stats.bavail * stats.bsize,
|
||||||
|
total: stats.blocks * stats.bsize,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { free: 0, total: 0, error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
|
registerVMHandlers(mainWindow);
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* VM Provisioner IPC Handler
|
||||||
|
* Wires the Electron wizard to VMDiskProvisioner.
|
||||||
|
* Add to src/main/index.js alongside the existing IPC handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
const { VMDiskProvisioner, DISK_PRESETS } = require('./vm-provisioner');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function registerVMHandlers(mainWindow) {
|
||||||
|
const provisioner = new VMDiskProvisioner();
|
||||||
|
|
||||||
|
// --- Get disk presets for wizard UI ---
|
||||||
|
ipcMain.handle('vm:get-presets', async () => {
|
||||||
|
return DISK_PRESETS;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Get current VM status ---
|
||||||
|
ipcMain.handle('vm:get-status', async () => {
|
||||||
|
try {
|
||||||
|
const status = await provisioner.getStatus();
|
||||||
|
// Also check for saved vmInfo from previous install
|
||||||
|
try {
|
||||||
|
const configPath = path.join(getInstallBase(), '.dashcaddy-config.json');
|
||||||
|
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||||
|
if (config.vmInfo) {
|
||||||
|
status.vmInfo = config.vmInfo;
|
||||||
|
status.diskSizeGB = config.vmInfo.diskSizeGB;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return status;
|
||||||
|
} catch (e) {
|
||||||
|
return { platform: process.platform, running: false, error: e.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Provision the VM sandbox ---
|
||||||
|
ipcMain.handle('vm:provision', async (event, opts) => {
|
||||||
|
try {
|
||||||
|
const result = await provisioner.provision({
|
||||||
|
...opts,
|
||||||
|
onProgress: (msg, pct) => {
|
||||||
|
mainWindow.webContents.send('vm:progress', { message: msg, percent: pct });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save vmInfo for uninstall
|
||||||
|
if (result.vmInfo) {
|
||||||
|
try {
|
||||||
|
const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json');
|
||||||
|
let config = {};
|
||||||
|
try { config = JSON.parse(await fs.readFile(configPath, 'utf8')); } catch {}
|
||||||
|
config.vmInfo = result.vmInfo;
|
||||||
|
config.diskBudgetGB = opts.diskSizeGB;
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.webContents.send('vm:complete', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
mainWindow.webContents.send('vm:error', { error: error.message });
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Destroy the VM sandbox (uninstall) ---
|
||||||
|
ipcMain.handle('vm:destroy', async (event, opts) => {
|
||||||
|
try {
|
||||||
|
// Load saved vmInfo
|
||||||
|
let vmInfo = opts.vmInfo;
|
||||||
|
if (!vmInfo) {
|
||||||
|
try {
|
||||||
|
const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json');
|
||||||
|
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||||
|
vmInfo = config.vmInfo;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vmInfo) {
|
||||||
|
return { success: false, error: 'No VM info found. Already uninstalled?' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await provisioner.destroy(vmInfo, {
|
||||||
|
exportDataPath: opts.exportDataPath || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Export data from VM (before uninstall) ---
|
||||||
|
ipcMain.handle('vm:export-data', async (event, opts) => {
|
||||||
|
try {
|
||||||
|
const result = await provisioner._exportData(opts.vmInfo, opts.exportPath);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstallBase() {
|
||||||
|
const { getPlatformInfo } = require('../shared/platform-utils');
|
||||||
|
return getPlatformInfo().defaultInstallPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerVMHandlers };
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
/**
|
||||||
|
* VM Disk Provisioner — creates a bounded virtual disk for DashCaddy.
|
||||||
|
*
|
||||||
|
* PLATFORM STRATEGY:
|
||||||
|
* Windows: Dedicated WSL2 distro with a fixed-size VHDX.
|
||||||
|
* Docker runs inside WSL2, all data lives in the VHDX.
|
||||||
|
* Uninstall = wsl --unregister (deletes VHDX instantly).
|
||||||
|
*
|
||||||
|
* macOS: Lima VM with a fixed disk image.
|
||||||
|
* Docker runs inside Lima, all data lives in the disk image.
|
||||||
|
* Uninstall = limactl delete (removes VM + disk).
|
||||||
|
*
|
||||||
|
* Linux: Sparse ext4 loopback image mounted at /opt/dashcaddy-data.
|
||||||
|
* Docker --data-root pointed at the mount.
|
||||||
|
* Uninstall = unmount + rm image file.
|
||||||
|
*
|
||||||
|
* The user picks a disk size (default 20GB). DashCaddy is physically
|
||||||
|
* unable to exceed it — the OS enforces the limit, not our code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const platformUtils = require('../shared/platform-utils');
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Presets users pick from in the wizard
|
||||||
|
const DISK_PRESETS = {
|
||||||
|
minimal: { sizeGB: 10, label: 'Minimal (10GB)', desc: 'DashCaddy only, a few small apps' },
|
||||||
|
balanced: { sizeGB: 30, label: 'Balanced (30GB)', desc: 'DashCaddy + media tools + containers' },
|
||||||
|
power: { sizeGB: 100, label: 'Power (100GB)', desc: 'DashCaddy + heavy apps + lots of containers' },
|
||||||
|
custom: { sizeGB: 0, label: 'Custom', desc: 'Pick your own size' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main provisioner class.
|
||||||
|
*/
|
||||||
|
class VMDiskProvisioner {
|
||||||
|
constructor() {
|
||||||
|
this.platform = platformUtils.detectOS();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision the full sandboxed environment.
|
||||||
|
*
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {number} opts.diskSizeGB — virtual disk size
|
||||||
|
* @param {string} opts.installPath — where DashCaddy app files live (host)
|
||||||
|
* @param {number} opts.apiPort
|
||||||
|
* @param {Object} opts.domain — { mode, domain, tld, email }
|
||||||
|
* @param {function} [opts.onProgress] — callback(statusMsg, pct)
|
||||||
|
* @returns {Object} { success, dockerContext, dashboardUrl, vmInfo }
|
||||||
|
*/
|
||||||
|
async provision(opts) {
|
||||||
|
const { diskSizeGB = 30, onProgress = () => {} } = opts;
|
||||||
|
|
||||||
|
onProgress('Checking prerequisites', 5);
|
||||||
|
await this._checkPrerequisites();
|
||||||
|
|
||||||
|
onProgress('Creating virtual disk (' + diskSizeGB + 'GB)', 15);
|
||||||
|
const diskInfo = await this._createDisk(opts);
|
||||||
|
|
||||||
|
onProgress('Starting sandbox environment', 40);
|
||||||
|
const envInfo = await this._startEnvironment(diskInfo, opts);
|
||||||
|
|
||||||
|
onProgress('Installing Docker in sandbox', 60);
|
||||||
|
await this._ensureDocker(envInfo);
|
||||||
|
|
||||||
|
onProgress('Deploying DashCaddy into sandbox', 75);
|
||||||
|
const deployInfo = await this._deployDashCaddy(envInfo, opts);
|
||||||
|
|
||||||
|
onProgress('Configuring services', 90);
|
||||||
|
await this._configureServices(envInfo, opts);
|
||||||
|
|
||||||
|
onProgress('Complete', 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
platform: this.platform,
|
||||||
|
diskSizeGB,
|
||||||
|
dockerContext: envInfo.dockerContext,
|
||||||
|
dashboardUrl: deployInfo.dashboardUrl,
|
||||||
|
vmInfo: {
|
||||||
|
type: envInfo.type,
|
||||||
|
name: envInfo.name,
|
||||||
|
diskPath: diskInfo.path,
|
||||||
|
diskSizeGB,
|
||||||
|
dockerDataRoot: envInfo.dockerDataRoot,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the sandboxed environment completely.
|
||||||
|
* @param {Object} vmInfo — from provision()
|
||||||
|
* @param {Object} opts — { exportDataPath: null }
|
||||||
|
*/
|
||||||
|
async destroy(vmInfo, opts = {}) {
|
||||||
|
// Export data first if requested
|
||||||
|
if (opts.exportDataPath) {
|
||||||
|
await this._exportData(vmInfo, opts.exportDataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.platform) {
|
||||||
|
case 'windows': return this._destroyWSL2(vmInfo);
|
||||||
|
case 'macos': return this._destroyLima(vmInfo);
|
||||||
|
case 'linux': return this._destroyLoopback(vmInfo);
|
||||||
|
default: throw new Error('Unsupported platform: ' + this.platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PREREQUISITES
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async _checkPrerequisites() {
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
switch (this.platform) {
|
||||||
|
case 'windows':
|
||||||
|
checks.push(this._checkCommand('wsl', '--status', 'WSL2'));
|
||||||
|
break;
|
||||||
|
case 'macos':
|
||||||
|
checks.push(this._checkCommand('limactl', 'version', 'Lima'));
|
||||||
|
break;
|
||||||
|
case 'linux':
|
||||||
|
// Need root or sudo for loopback mount
|
||||||
|
if (process.getuid && process.getuid() !== 0) {
|
||||||
|
// Check if we can sudo
|
||||||
|
try { await execAsync('sudo -n true', { timeout: 5000 }); }
|
||||||
|
catch { throw new Error('Linux install needs root or passwordless sudo for loopback mount'); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(checks);
|
||||||
|
const failed = results.filter(r => !r.ok);
|
||||||
|
if (failed.length) {
|
||||||
|
throw new Error('Missing: ' + failed.map(f => f.name).join(', ') +
|
||||||
|
'. Install instructions: https://dashcaddy.net/docs/installation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _checkCommand(cmd, versionArg, friendlyName) {
|
||||||
|
try {
|
||||||
|
await execAsync(`${cmd} ${versionArg}`, { timeout: 10000 });
|
||||||
|
return { ok: true, name: friendlyName };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, name: friendlyName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// WINDOWS: WSL2 Dedicated Distro
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async _createDisk(opts) {
|
||||||
|
if (this.platform === 'windows') return this._createWSL2Disk(opts);
|
||||||
|
if (this.platform === 'macos') return this._createLimaDisk(opts);
|
||||||
|
return this._createLoopbackDisk(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _createWSL2Disk(opts) {
|
||||||
|
const distroName = 'dashcaddy';
|
||||||
|
const { diskSizeGB = 30 } = opts;
|
||||||
|
const wslPath = opts.installPath || path.join(process.env.LOCALAPPDATA || 'C:\\DashCaddy', 'DashCaddy');
|
||||||
|
const vhdxPath = path.join(wslPath, 'data.vhdx');
|
||||||
|
|
||||||
|
// Check if distro already exists
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('wsl -l -q', { timeout: 10000 });
|
||||||
|
if (stdout.includes(distroName)) {
|
||||||
|
return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: true };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Download a minimal rootfs (Alpine for smallest footprint)
|
||||||
|
await fs.mkdir(wslPath, { recursive: true });
|
||||||
|
const rootfsUrl = 'https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz';
|
||||||
|
const rootfsPath = path.join(wslPath, 'rootfs.tar.gz');
|
||||||
|
|
||||||
|
await execAsync(`curl -L -o "${rootfsPath}" "${rootfsUrl}"`, { timeout: 120000 });
|
||||||
|
|
||||||
|
// Import as a new WSL2 distro — the VHDX is created automatically
|
||||||
|
// and capped by .wslconfig max disk size
|
||||||
|
await execAsync(`wsl --import ${distroName} "${wslPath}" "${rootfsPath}" --version 2`, { timeout: 60000 });
|
||||||
|
|
||||||
|
// Set disk size limit via wsl config
|
||||||
|
const wslconfigPath = path.join(wslPath, '.wslconfig');
|
||||||
|
await fs.writeFile(wslconfigPath, [
|
||||||
|
`[wsl2]`,
|
||||||
|
`vmDiskSize=${diskSizeGB}GB`,
|
||||||
|
`memory=2GB`,
|
||||||
|
`processors=2`,
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
// Clean up rootfs download
|
||||||
|
await fs.unlink(rootfsPath).catch(() => {});
|
||||||
|
|
||||||
|
return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startEnvironment(diskInfo, opts) {
|
||||||
|
if (this.platform === 'windows') return this._startWSL2(diskInfo, opts);
|
||||||
|
if (this.platform === 'macos') return this._startLima(diskInfo, opts);
|
||||||
|
return this._startLoopback(diskInfo, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startWSL2(diskInfo, opts) {
|
||||||
|
const { distroName } = diskInfo;
|
||||||
|
|
||||||
|
// Start the distro and install Docker inside
|
||||||
|
const wslExec = (cmd) => execAsync(`wsl -d ${distroName} -- sh -c "${cmd}"`, { timeout: 60000 });
|
||||||
|
|
||||||
|
// Update apk and install Docker + dependencies
|
||||||
|
await wslExec('apk update && apk add docker docker-cli-compose openrc ca-certificates curl');
|
||||||
|
await wslExec('rc-update add docker default && service docker start');
|
||||||
|
|
||||||
|
// Create Docker data directory inside the VM
|
||||||
|
await wslExec('mkdir -p /var/lib/docker /opt/dashcaddy');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'wsl2',
|
||||||
|
name: distroName,
|
||||||
|
dockerContext: 'dashcaddy-wsl',
|
||||||
|
dockerDataRoot: '/var/lib/docker',
|
||||||
|
exec: wslExec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// macOS: Lima VM
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async _createLimaDisk(opts) {
|
||||||
|
const { diskSizeGB = 30 } = opts;
|
||||||
|
const vmName = 'dashcaddy';
|
||||||
|
const limaDir = path.join(process.env.HOME, '.lima', vmName);
|
||||||
|
|
||||||
|
// Check if VM already exists
|
||||||
|
try {
|
||||||
|
await execAsync(`limactl list ${vmName}`, { timeout: 10000 });
|
||||||
|
return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: true };
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Create Lima config with fixed disk
|
||||||
|
const config = {
|
||||||
|
vmType: 'qemu',
|
||||||
|
arch: 'x86_64',
|
||||||
|
images: [{
|
||||||
|
location: 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img',
|
||||||
|
arch: 'x86_64',
|
||||||
|
}],
|
||||||
|
cpus: 2,
|
||||||
|
memory: '2GiB',
|
||||||
|
disk: diskSizeGB + 'GiB',
|
||||||
|
mounts: [],
|
||||||
|
containerd: { system: false, user: false },
|
||||||
|
provision: {
|
||||||
|
mode: 'system',
|
||||||
|
script: 'apt-get update && apt-get install -y docker.io docker-compose-plugin',
|
||||||
|
},
|
||||||
|
// Forward the API port
|
||||||
|
portForwards: [{
|
||||||
|
guestSocket: '/var/run/docker.sock',
|
||||||
|
hostSocket: path.join(limaDir, 'sock', 'docker.sock'),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const configPath = path.join(limaDir, 'lima.yaml');
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(configPath, require('yaml').stringify ? require('yaml').stringify(config) : JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
await execAsync(`limactl start --name=${vmName} ${configPath}`, { timeout: 300000 });
|
||||||
|
|
||||||
|
return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startLima(diskInfo, opts) {
|
||||||
|
const { vmName } = diskInfo;
|
||||||
|
|
||||||
|
// Ensure VM is running
|
||||||
|
try { await execAsync(`limactl start ${vmName}`, { timeout: 60000 }); } catch {}
|
||||||
|
|
||||||
|
const limaExec = (cmd) => execAsync(`limactl shell ${vmName} -- bash -c "${cmd}"`, { timeout: 60000 });
|
||||||
|
|
||||||
|
// Ensure Docker is running
|
||||||
|
await limaExec('service docker start || true');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'lima',
|
||||||
|
name: vmName,
|
||||||
|
dockerContext: 'dashcaddy-lima',
|
||||||
|
dockerDataRoot: '/var/lib/docker',
|
||||||
|
exec: limaExec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LINUX: Loopback ext4 image
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async _createLoopbackDisk(opts) {
|
||||||
|
const { diskSizeGB = 30 } = opts;
|
||||||
|
const imagePath = '/opt/dashcaddy-data.raw';
|
||||||
|
const mountPoint = '/opt/dashcaddy-data';
|
||||||
|
|
||||||
|
// Check if already mounted
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo mounted', { timeout: 5000 });
|
||||||
|
if (stdout.includes('mounted')) {
|
||||||
|
return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: true };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Create sparse image (only uses space as data fills — starts at ~0 bytes)
|
||||||
|
const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo';
|
||||||
|
await execAsync(`truncate -s ${diskSizeGB}G "${imagePath}"`, { timeout: 30000 });
|
||||||
|
|
||||||
|
// Format as ext4
|
||||||
|
await execAsync(`${sudo} mkfs.ext4 -F -L dashcaddy "${imagePath}"`, { timeout: 60000 });
|
||||||
|
|
||||||
|
// Mount
|
||||||
|
await execAsync(`${sudo} mkdir -p "${mountPoint}"`, { timeout: 5000 });
|
||||||
|
await execAsync(`${sudo} mount -o loop "${imagePath}" "${mountPoint}"`, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Add to fstab for persistence across reboots
|
||||||
|
const fstabEntry = `${imagePath} ${mountPoint} ext4 loop,defaults 0 0`;
|
||||||
|
await execAsync(`grep -q '${imagePath}' /etc/fstab || echo '${fstabEntry}' | ${sudo} tee -a /etc/fstab`, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Point Docker data-root at the mounted volume
|
||||||
|
await this._configureDockerDataRoot(mountPoint + '/docker', sudo);
|
||||||
|
|
||||||
|
return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startLoopback(diskInfo, opts) {
|
||||||
|
const { mountPoint } = diskInfo;
|
||||||
|
const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo';
|
||||||
|
|
||||||
|
// Ensure mounted
|
||||||
|
try {
|
||||||
|
await execAsync(`mountpoint -q ${mountPoint} || ${sudo} mount -o loop ${diskInfo.imagePath} ${mountPoint}`, { timeout: 10000 });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Restart Docker to pick up new data-root
|
||||||
|
await execAsync(`${sudo} systemctl restart docker`, { timeout: 30000 }).catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'loopback',
|
||||||
|
name: 'dashcaddy-loopback',
|
||||||
|
dockerContext: 'default',
|
||||||
|
dockerDataRoot: mountPoint + '/docker',
|
||||||
|
exec: (cmd) => execAsync(cmd, { timeout: 60000 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _configureDockerDataRoot(dataRoot, sudo) {
|
||||||
|
const daemonJsonPath = '/etc/docker/daemon.json';
|
||||||
|
let daemonJson = {};
|
||||||
|
try {
|
||||||
|
daemonJson = JSON.parse(await fs.readFile(daemonJsonPath, 'utf8'));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
daemonJson['data-root'] = dataRoot;
|
||||||
|
|
||||||
|
await execAsync(`${sudo} mkdir -p ${dataRoot}`, { timeout: 5000 });
|
||||||
|
await execAsync(`${sudo} bash -c 'cat > ${daemonJsonPath} << EOF\n${JSON.stringify(daemonJson, null, 2)}\nEOF'`, { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// DEPLOY + CONFIGURE (shared across platforms)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async _ensureDocker(envInfo) {
|
||||||
|
// Docker was installed during VM creation per-platform.
|
||||||
|
// Verify it's actually running.
|
||||||
|
if (envInfo.exec) {
|
||||||
|
try {
|
||||||
|
await envInfo.exec('docker info > /dev/null 2>&1');
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Try starting
|
||||||
|
if (this.platform === 'windows') await envInfo.exec('service docker start || true');
|
||||||
|
if (this.platform === 'macos') await envInfo.exec('service docker start || true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _deployDashCaddy(envInfo, opts) {
|
||||||
|
const apiPort = opts.apiPort || 3001;
|
||||||
|
const dashboardPort = opts.domain?.mode === 'public' ? null : (opts.dashboardPort || 8080);
|
||||||
|
|
||||||
|
// Inside the VM, download and run DashCaddy
|
||||||
|
// The VM has Docker running — we deploy the same container image
|
||||||
|
const deployScript = `
|
||||||
|
mkdir -p /opt/dashcaddy && cd /opt/dashcaddy
|
||||||
|
curl -fsSL https://get.dashcaddy.net/release/latest.tar.gz | tar xz
|
||||||
|
cd dashcaddy-api && docker build -t dashcaddy-api .
|
||||||
|
docker run -d --name dashcaddy-api --restart unless-stopped \\
|
||||||
|
-p ${apiPort}:${apiPort} \\
|
||||||
|
-v /opt/dashcaddy/data:/app/data \\
|
||||||
|
-v /opt/dashcaddy/status:/app/status \\
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \\
|
||||||
|
-e NODE_ENV=production \\
|
||||||
|
-e PORT=${apiPort} \\
|
||||||
|
dashcaddy-api
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (envInfo.exec) {
|
||||||
|
await envInfo.exec(deployScript.replace(/\n/g, ' && '));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if (opts.domain?.mode === 'public') {
|
||||||
|
url = `https://${opts.domain.domain}`;
|
||||||
|
} else if (opts.domain?.mode === 'custom-tld') {
|
||||||
|
url = `https://dashcaddy${opts.domain.tld}`;
|
||||||
|
} else {
|
||||||
|
url = `http://localhost:${dashboardPort || 8080}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dashboardUrl: url };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _configureServices(envInfo, opts) {
|
||||||
|
// Port forwarding from host to VM
|
||||||
|
if (this.platform === 'windows') {
|
||||||
|
// WSL2 auto-forwards localhost ports to the host
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.platform === 'macos') {
|
||||||
|
// Lima forwards are configured in the VM config
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Linux: container is directly accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// DESTROY (uninstall)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async _destroyWSL2(vmInfo) {
|
||||||
|
await execAsync(`wsl --unregister ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 });
|
||||||
|
// VHDX is deleted by WSL on unregister
|
||||||
|
return { success: true, message: 'WSL2 distro deleted — all data removed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _destroyLima(vmInfo) {
|
||||||
|
await execAsync(`limactl delete -f ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 });
|
||||||
|
return { success: true, message: 'Lima VM deleted — all data removed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _destroyLoopback(vmInfo) {
|
||||||
|
const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo';
|
||||||
|
await execAsync(`${sudo} umount ${vmInfo.mountPoint || '/opt/dashcaddy-data'}`, { timeout: 10000 }).catch(() => {});
|
||||||
|
await execAsync(`rm -f ${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}`, { timeout: 5000 });
|
||||||
|
// Remove from fstab
|
||||||
|
await execAsync(`${sudo} sed -i '\\#${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}#d' /etc/fstab`, { timeout: 5000 }).catch(() => {});
|
||||||
|
return { success: true, message: 'Virtual disk unmounted and deleted — all data removed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _exportData(vmInfo, exportPath) {
|
||||||
|
// Export DashCaddy config + service definitions before destroy
|
||||||
|
if (vmInfo.type === 'wsl2') {
|
||||||
|
await execAsync(`wsl -d ${vmInfo.name} -- tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data /opt/dashcaddy/status`, { timeout: 60000 });
|
||||||
|
await execAsync(`wsl -d ${vmInfo.name} -- cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 });
|
||||||
|
} else if (vmInfo.type === 'lima') {
|
||||||
|
await execAsync(`limactl shell ${vmInfo.name} -- sudo tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data`, { timeout: 60000 });
|
||||||
|
await execAsync(`limactl shell ${vmInfo.name} -- sudo cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 });
|
||||||
|
} else if (vmInfo.type === 'loopback') {
|
||||||
|
await execAsync(`tar czf "${exportPath}" -C ${vmInfo.mountPoint} data`, { timeout: 60000 });
|
||||||
|
}
|
||||||
|
return { success: true, exportPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// STATUS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
async getStatus() {
|
||||||
|
const info = { platform: this.platform, running: false };
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (this.platform) {
|
||||||
|
case 'windows': {
|
||||||
|
const { stdout } = await execAsync('wsl -l -v', { timeout: 10000 });
|
||||||
|
info.running = stdout.includes('dashcaddy') && stdout.includes('Running');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'macos': {
|
||||||
|
const { stdout } = await execAsync('limactl list --json', { timeout: 10000 });
|
||||||
|
const vms = JSON.parse(stdout);
|
||||||
|
info.running = vms.some(v => v.name === 'dashcaddy' && v.status === 'Running');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'linux': {
|
||||||
|
const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo yes', { timeout: 5000 });
|
||||||
|
info.running = stdout.includes('yes');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VMDiskProvisioner, DISK_PRESETS };
|
||||||
@@ -95,6 +95,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('uninstall-error', (event, data) => callback(data));
|
ipcRenderer.on('uninstall-error', (event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// --- VM Disk Sandbox ---
|
||||||
|
vmGetPresets: () => ipcRenderer.invoke('vm:get-presets'),
|
||||||
|
vmGetStatus: () => ipcRenderer.invoke('vm:get-status'),
|
||||||
|
vmProvision: (opts) => ipcRenderer.invoke('vm:provision', opts),
|
||||||
|
vmDestroy: (opts) => ipcRenderer.invoke('vm:destroy', opts),
|
||||||
|
vmExportData: (opts) => ipcRenderer.invoke('vm:export-data', opts),
|
||||||
|
getDiskSpace: (path) => ipcRenderer.invoke('get-disk-space', path),
|
||||||
|
onVMProgress: (callback) => {
|
||||||
|
ipcRenderer.on('vm:progress', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onVMComplete: (callback) => {
|
||||||
|
ipcRenderer.on('vm:complete', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onVMError: (callback) => {
|
||||||
|
ipcRenderer.on('vm:error', (event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
// Remove listeners
|
// Remove listeners
|
||||||
removeListener: (channel) => {
|
removeListener: (channel) => {
|
||||||
ipcRenderer.removeAllListeners(channel);
|
ipcRenderer.removeAllListeners(channel);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* VM Disk Budget Step — rendered inside the Electron wizard.
|
||||||
|
* Shows disk size presets, a custom slider, and real-time space check.
|
||||||
|
* Add to wizard.js as a new render step between 'folder' and 'tier'.
|
||||||
|
*
|
||||||
|
* Exported function: renderDiskBudgetStep()
|
||||||
|
* State updates: state.diskBudget.preset, state.diskBudget.customSizeGB
|
||||||
|
*/
|
||||||
|
|
||||||
|
function renderDiskBudgetStep() {
|
||||||
|
const presets = [
|
||||||
|
{ id: 'minimal', icon: '💽', sizeGB: 10, label: 'Minimal', desc: 'DashCaddy only, a few small apps' },
|
||||||
|
{ id: 'balanced', icon: '💿', sizeGB: 30, label: 'Balanced', desc: 'DashCaddy + media tools + containers' },
|
||||||
|
{ id: 'power', icon: '🧊', sizeGB: 100, label: 'Power', desc: 'DashCaddy + heavy apps + lots of containers' },
|
||||||
|
{ id: 'custom', icon: '⚙️', sizeGB: 0, label: 'Custom', desc: 'Pick your own size' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedPreset = state.diskBudget?.preset || 'balanced';
|
||||||
|
const selectedSize = state.diskBudget?.customSizeGB || presets.find(p => p.id === selectedPreset)?.sizeGB || 30;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<h2>Storage Budget</h2>
|
||||||
|
<p>DashCaddy creates a <strong>sandboxed virtual disk</strong> for all its data.
|
||||||
|
It can never exceed this limit — your main drive stays safe.</p>
|
||||||
|
<p class="hint" style="margin-bottom: 20px;">
|
||||||
|
💡 The disk starts nearly empty and only grows as you add apps and data.
|
||||||
|
Deleting DashCaddy removes the entire disk instantly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="disk-presets" style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;">
|
||||||
|
${presets.map(p => `
|
||||||
|
<div class="disk-preset-card ${selectedPreset === p.id ? 'selected' : ''}"
|
||||||
|
onclick="selectDiskPreset('${p.id}', ${p.sizeGB})"
|
||||||
|
style="padding: 16px; border: 2px solid ${selectedPreset === p.id ? '#6366f1' : 'var(--border, #333)'}; border-radius: 10px; cursor: pointer; transition: all 0.2s; ${selectedPreset === p.id ? 'background: rgba(99, 102, 241, 0.1);' : ''}">
|
||||||
|
<div style="font-size: 2rem; margin-bottom: 8px;">${p.icon}</div>
|
||||||
|
<div style="font-weight: 600; font-size: 1.05rem;">${p.label}</div>
|
||||||
|
<div style="font-size: 0.85rem; color: var(--muted, #888); margin-top: 4px;">
|
||||||
|
${p.sizeGB > 0 ? p.sizeGB + 'GB' : 'Custom'} — ${p.desc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${selectedPreset === 'custom' ? `
|
||||||
|
<div class="folder-input" style="margin-bottom: 16px;">
|
||||||
|
<label>Custom Disk Size</label>
|
||||||
|
<div class="input-row" style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<input type="range" id="disk-slider"
|
||||||
|
min="5" max="500" step="5"
|
||||||
|
value="${selectedSize}"
|
||||||
|
oninput="updateDiskSize(this.value)"
|
||||||
|
style="flex: 1;">
|
||||||
|
<span id="disk-size-display" style="font-size: 1.3rem; font-weight: 700; min-width: 80px; text-align: right;">
|
||||||
|
${selectedSize}GB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Min 5GB, Max 500GB. DashCaddy uses a sparse image — it only consumes real disk space as data fills.</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div style="padding: 12px 16px; background: rgba(99, 102, 241, 0.08); border-radius: 8px; border: 1px solid rgba(99, 102, 241, 0.2); margin-bottom: 16px;">
|
||||||
|
<strong>${selectedSize}GB</strong> virtual disk will be created.
|
||||||
|
The sandbox isolates Docker, all containers, and all DashCaddy data inside it.
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div id="disk-space-check" style="margin-top: 12px;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State management helpers — call from wizard.js
|
||||||
|
function selectDiskPreset(presetId, sizeGB) {
|
||||||
|
if (!state.diskBudget) state.diskBudget = {};
|
||||||
|
state.diskBudget.preset = presetId;
|
||||||
|
if (presetId !== 'custom') {
|
||||||
|
state.diskBudget.diskSizeGB = sizeGB;
|
||||||
|
}
|
||||||
|
checkDiskSpace(sizeGB);
|
||||||
|
render(); // re-render the step
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDiskSize(val) {
|
||||||
|
const sizeGB = parseInt(val);
|
||||||
|
if (!state.diskBudget) state.diskBudget = {};
|
||||||
|
state.diskBudget.diskSizeGB = sizeGB;
|
||||||
|
state.diskBudget.customSizeGB = sizeGB;
|
||||||
|
document.getElementById('disk-size-display').textContent = sizeGB + 'GB';
|
||||||
|
checkDiskSpace(sizeGB);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDiskSpace(sizeGB) {
|
||||||
|
const el = document.getElementById('disk-space-check');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await window.electronAPI.getDiskSpace(state.paths.install || '');
|
||||||
|
const freeGB = Math.round(info.free / 1024 / 1024 / 1024);
|
||||||
|
const neededGB = sizeGB + 2; // 2GB buffer for DashCaddy itself
|
||||||
|
|
||||||
|
if (freeGB < neededGB) {
|
||||||
|
el.innerHTML = `<div style="padding: 10px 14px; background: rgba(239, 68, 68, 0.1); border-radius: 6px; border: 1px solid rgba(239, 68, 68, 0.3); color: #f87171; font-size: 0.85rem;">
|
||||||
|
⚠️ Not enough free space. You have ${freeGB}GB free, but need ${neededGB}GB.
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
el.innerHTML = `<div style="padding: 10px 14px; background: rgba(34, 197, 94, 0.1); border-radius: 6px; border: 1px solid rgba(34, 197, 94, 0.2); color: #4ade80; font-size: 0.85rem;">
|
||||||
|
✓ You have ${freeGB}GB free — plenty of room for a ${sizeGB}GB disk.
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -206,6 +206,7 @@
|
|||||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||||
|
<button id="log-insights-btn" aria-label="Log Insights">🔍 Insights</button>
|
||||||
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -950,6 +951,7 @@
|
|||||||
<script src="/js/xterm-fit.min.js" defer></script>
|
<script src="/js/xterm-fit.min.js" defer></script>
|
||||||
|
|
||||||
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
|
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
|
||||||
|
<script src="/js/log-insights.js" defer></script>
|
||||||
<script src="/js/tailscale-devices.js" defer></script>
|
<script src="/js/tailscale-devices.js" defer></script>
|
||||||
|
|
||||||
<!-- Bundled JS (built with: npm run build) -->
|
<!-- Bundled JS (built with: npm run build) -->
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// ========== LOG INSIGHTS PANEL ==========
|
||||||
|
(function() {
|
||||||
|
injectModal('log-insights-modal', `<div id="log-insights-modal" class="weather-modal">
|
||||||
|
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||||
|
<h3>🔍 Log Insights</h3>
|
||||||
|
<p class="modal-subtitle">Who's accessing your server and what they're doing — in plain English.</p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
||||||
|
<label class="text-muted-sm">Period:</label>
|
||||||
|
<select id="li-period" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||||
|
<option value="1">Last 1 hour</option>
|
||||||
|
<option value="6">Last 6 hours</option>
|
||||||
|
<option value="24" selected>Last 24 hours</option>
|
||||||
|
<option value="168">Last 7 days</option>
|
||||||
|
</select>
|
||||||
|
<button id="li-refresh" class="btn-sm">🔄 Refresh</button>
|
||||||
|
<span style="flex: 1;"></span>
|
||||||
|
<button id="li-dispose-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--warn-fg, #f0c674); border-color: var(--warn-fg, #f0c674);">🧹 Clean Old Logs</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plain English Insights -->
|
||||||
|
<div id="li-insights" style="margin-bottom: 16px;"></div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div id="li-summary" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;"></div>
|
||||||
|
|
||||||
|
<!-- Top IPs Table -->
|
||||||
|
<div id="li-ips-section">
|
||||||
|
<h4 style="margin: 12px 0 8px; font-size: 0.95rem;">Top Visitors</h4>
|
||||||
|
<div id="li-ips-table" class="scroll-container" style="max-height: 300px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Info -->
|
||||||
|
<div id="li-storage" style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"></div>
|
||||||
|
|
||||||
|
<div class="weather-modal-buttons">
|
||||||
|
<button id="li-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
const modal = document.getElementById('log-insights-modal');
|
||||||
|
const openBtn = document.getElementById('log-insights-btn');
|
||||||
|
const closeBtn = document.getElementById('li-close');
|
||||||
|
const refreshBtn = document.getElementById('li-refresh');
|
||||||
|
const disposeBtn = document.getElementById('li-dispose-btn');
|
||||||
|
const periodSel = document.getElementById('li-period');
|
||||||
|
const insightsDiv = document.getElementById('li-insights');
|
||||||
|
const summaryDiv = document.getElementById('li-summary');
|
||||||
|
const ipsDiv = document.getElementById('li-ips-table');
|
||||||
|
const storageDiv = document.getElementById('li-storage');
|
||||||
|
|
||||||
|
if (openBtn) {
|
||||||
|
openBtn.addEventListener('click', () => { modal.style.display = 'flex'; loadInsights(); });
|
||||||
|
}
|
||||||
|
closeBtn.addEventListener('click', () => modal.style.display = 'none');
|
||||||
|
refreshBtn.addEventListener('click', loadInsights);
|
||||||
|
periodSel.addEventListener('change', loadInsights);
|
||||||
|
disposeBtn.addEventListener('click', showDisposePreview);
|
||||||
|
|
||||||
|
async function loadInsights() {
|
||||||
|
const hours = periodSel.value;
|
||||||
|
insightsDiv.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Analyzing logs...</div>';
|
||||||
|
summaryDiv.innerHTML = '';
|
||||||
|
ipsDiv.innerHTML = '';
|
||||||
|
storageDiv.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/log-insights?hours=' + hours);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) { insightsDiv.innerHTML = '<div class="panel-empty">Error: ' + data.error + '</div>'; return; }
|
||||||
|
|
||||||
|
// Render insights as plain English cards
|
||||||
|
let insightsHtml = '';
|
||||||
|
(data.insights || []).forEach(function(ins) {
|
||||||
|
const sevColor = ins.severity === 'warning' ? 'var(--warn-fg, #f0c674)' :
|
||||||
|
ins.severity === 'critical' ? 'var(--bad-fg, #ff6b6b)' :
|
||||||
|
ins.severity === 'ok' ? 'var(--good-fg, #98c379)' : 'var(--muted)';
|
||||||
|
insightsHtml += '<div style="padding: 10px 14px; margin-bottom: 8px; background: var(--bg); border-radius: 6px; border-left: 3px solid ' + sevColor + ';">' +
|
||||||
|
'<strong style="font-size: 0.9rem;">' + ins.title + '</strong><br>' +
|
||||||
|
'<span style="font-size: 0.85rem; color: var(--muted);">' + ins.plain + '</span></div>';
|
||||||
|
});
|
||||||
|
insightsDiv.innerHTML = insightsHtml;
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
var s = data.summary;
|
||||||
|
summaryDiv.innerHTML =
|
||||||
|
statCard('Requests', s.totalRequests) +
|
||||||
|
statCard('Unique IPs', s.uniqueIPs) +
|
||||||
|
statCard('Security Events', s.securityEvents) +
|
||||||
|
statCard('Failed Actions', s.failedActions);
|
||||||
|
|
||||||
|
// Top IPs table
|
||||||
|
var ips = data.topIPs || [];
|
||||||
|
if (ips.length === 0) {
|
||||||
|
ipsDiv.innerHTML = '<div class="panel-empty">No activity in this period.</div>';
|
||||||
|
} else {
|
||||||
|
var html = '<table style="width: 100%; font-size: 0.85rem; border-collapse: collapse;">';
|
||||||
|
html += '<tr style="border-bottom: 1px solid var(--border);"><th style="text-align:left; padding: 6px;">IP Address</th><th style="text-align:right; padding: 6px;">Requests</th><th style="text-align:right; padding: 6px;">Failures</th><th style="text-align:left; padding: 6px;">Top Actions</th><th style="text-align:left; padding: 6px;">Last Seen</th></tr>';
|
||||||
|
ips.forEach(function(ip) {
|
||||||
|
var failStyle = ip.failures > 0 ? 'color: var(--bad-fg, #ff6b6b); font-weight: 600;' : '';
|
||||||
|
var actions = (ip.topActions || []).map(function(a) { return a[0]; }).join(', ');
|
||||||
|
var lastSeen = ip.lastSeen ? new Date(ip.lastSeen).toLocaleString() : '?';
|
||||||
|
html += '<tr style="border-bottom: 1px solid var(--border);">' +
|
||||||
|
'<td style="padding: 6px; font-family: monospace;">' + ip.ip + '</td>' +
|
||||||
|
'<td style="padding: 6px; text-align: right;">' + ip.count + '</td>' +
|
||||||
|
'<td style="padding: 6px; text-align: right; ' + failStyle + '">' + ip.failures + '</td>' +
|
||||||
|
'<td style="padding: 6px;">' + actions + '</td>' +
|
||||||
|
'<td style="padding: 6px; color: var(--muted);">' + lastSeen + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
html += '</table>';
|
||||||
|
ipsDiv.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage info
|
||||||
|
var st = data.storage || {};
|
||||||
|
var stHtml = '<strong style="font-size: 0.85rem;">Log Storage</strong><br>';
|
||||||
|
if (st.auditLog) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Audit log: ' + st.auditLog.sizeMB + ' MB (' + st.auditLog.entries + ' entries)</span><br>';
|
||||||
|
if (st.securityEvents) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Security events: ' + st.securityEvents.sizeMB + ' MB (' + st.securityEvents.entries + ' entries)</span>';
|
||||||
|
storageDiv.innerHTML = stHtml;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
insightsDiv.innerHTML = '<div class="panel-empty">Failed to load: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statCard(label, value) {
|
||||||
|
return '<div style="text-align: center; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
|
||||||
|
'<div style="font-size: 1.5rem; font-weight: 700;">' + value + '</div>' +
|
||||||
|
'<div style="font-size: 0.75rem; color: var(--muted);">' + label + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDisposePreview() {
|
||||||
|
var keepDays = prompt('Delete logs older than how many days?', '30');
|
||||||
|
if (!keepDays) return;
|
||||||
|
keepDays = parseInt(keepDays);
|
||||||
|
if (isNaN(keepDays) || keepDays < 1) { alert('Invalid number'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/v1/log-insights/dispose', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keepDays: keepDays })
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success) { alert('Error: ' + data.error); return; }
|
||||||
|
|
||||||
|
var msg = data.message + '\n\n' +
|
||||||
|
'Audit entries to delete: ' + data.wouldDelete.auditEntries + '\n' +
|
||||||
|
'Security events to delete: ' + data.wouldDelete.securityEvents + '\n\n' +
|
||||||
|
'Click OK to confirm deletion.';
|
||||||
|
if (confirm(msg)) {
|
||||||
|
await executeDispose(keepDays);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDispose(keepDays) {
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/v1/log-insights/dispose', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keepDays: keepDays, confirm: true })
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
if (!data.success) { alert('Error: ' + data.error); return; }
|
||||||
|
|
||||||
|
alert('Cleaned up!\n\nDeleted: ' + data.deleted.auditEntries + ' audit entries, ' + data.deleted.securityEvents + ' security events.\nRemaining: ' + data.remaining.auditEntries + ' audit, ' + data.remaining.securityEvents + ' security.');
|
||||||
|
loadInsights();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectModal(id, html) {
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.innerHTML = html;
|
||||||
|
document.body.appendChild(div.firstElementChild);
|
||||||
|
}
|
||||||
|
})();
|
||||||