Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5844bfed72 | ||
|
|
cf57093388 | ||
|
|
e7a7e1efa4 | ||
|
|
50391d692d | ||
|
|
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-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-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
|
||||
- **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;
|
||||
@@ -2541,7 +2541,386 @@ const APP_TEMPLATES = {
|
||||
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"authelia": {
|
||||
name: "Authelia",
|
||||
description: "Single sign-on portal and authentication gateway for web apps",
|
||||
icon: "\u{1F6E1}\u{FE0F}",
|
||||
category: "Security",
|
||||
popularity: 82,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "authelia/authelia:latest",
|
||||
ports: ["{{PORT}}:9091"],
|
||||
volumes: ["/opt/authelia/config:/config"],
|
||||
environment: {
|
||||
"AUTHELIA_STORAGE": "local",
|
||||
"AUTHELIA_STORAGE_LOCAL_PATH": "/config/db.sqlite3"
|
||||
}
|
||||
},
|
||||
subdomain: "authelia",
|
||||
defaultPort: 9091,
|
||||
healthCheck: "/api/health",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: [
|
||||
"Edit /opt/authelia/config/configuration.yml with your domain and settings",
|
||||
"Configure users in /opt/authelia/config/users_database.yml",
|
||||
"Set up Caddy forward_auth to protect your services behind Authelia",
|
||||
"Configure 2FA (TOTP, WebAuthn) in the Authelia portal"
|
||||
],
|
||||
dashcaddyIntegration: {
|
||||
ssoCompatible: true,
|
||||
forwardAuth: true,
|
||||
caddySnippet: "forward_auth authelia.<domain>:9091 { uri /api/verify?rd=https://authelia.<domain> copy_headers Remote-User Remote-Groups Remote-Name Remote-Email }"
|
||||
}
|
||||
},
|
||||
|
||||
"keycloak": {
|
||||
name: "Keycloak",
|
||||
description: "Open source identity and access management with SSO, OIDC, and SAML",
|
||||
icon: "\u{1F511}",
|
||||
category: "Security",
|
||||
popularity: 85,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "quay.io/keycloak/keycloak:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: ["/opt/keycloak/data:/opt/keycloak/data"],
|
||||
environment: {
|
||||
"KEYCLOAK_ADMIN": "admin",
|
||||
"KEYCLOAK_ADMIN_PASSWORD": "{{GENERATED_SECRET}}",
|
||||
"KC_DB": "dev-file",
|
||||
"KC_HOSTNAME": "{{SUBDOMAIN}}.{{TLD}}"
|
||||
},
|
||||
command: ["start-dev"]
|
||||
},
|
||||
subdomain: "keycloak",
|
||||
defaultPort: 8090,
|
||||
healthCheck: "/health/ready",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Log in with admin / generated password",
|
||||
"Create a realm for your applications",
|
||||
"Configure OIDC clients for each app you want to protect",
|
||||
"Set up identity providers (Google, GitHub, etc.) for social login"
|
||||
],
|
||||
secrets: [{
|
||||
envVar: "GENERATED_SECRET",
|
||||
label: "Keycloak Admin Password",
|
||||
description: "Initial admin password",
|
||||
type: "password",
|
||||
required: true,
|
||||
generate: "alphanumeric",
|
||||
length: 32
|
||||
}]
|
||||
},
|
||||
|
||||
"vikunja": {
|
||||
name: "Vikunja",
|
||||
description: "Self-hosted to-do app with lists, kanban boards, and teams",
|
||||
icon: "\u2705",
|
||||
category: "Productivity",
|
||||
popularity: 72,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "vikunja/vikunja:latest",
|
||||
ports: ["{{PORT}}:3456"],
|
||||
volumes: ["/opt/vikunja/files:/app/vikunja/files", "/opt/vikunja/db:/app/vikunja/database"],
|
||||
environment: {
|
||||
"VIKUNJA_SERVICE_JWTSECRET": "{{GENERATED_SECRET}}",
|
||||
"VIKUNJA_SERVICE_FRONTENDURL": "https://{{SUBDOMAIN}}.{{TLD}}"
|
||||
}
|
||||
},
|
||||
subdomain: "vikunja",
|
||||
defaultPort: 3456,
|
||||
healthCheck: "/api/v1/info",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Register the first user account", "Create projects and tasks", "Set up teams for shared task management"],
|
||||
secrets: [{
|
||||
envVar: "GENERATED_SECRET",
|
||||
label: "JWT Secret",
|
||||
description: "Secret for signing auth tokens",
|
||||
type: "password",
|
||||
required: true,
|
||||
generate: "alphanumeric",
|
||||
length: 48
|
||||
}]
|
||||
},
|
||||
|
||||
"openproject": {
|
||||
name: "OpenProject",
|
||||
description: "Project management with Gantt charts, time tracking, and Scrum boards",
|
||||
icon: "\u{1F4CB}",
|
||||
category: "Productivity",
|
||||
popularity: 75,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "openproject/openproject:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: ["/opt/openproject/pgdata:/var/openproject/pgdata", "/opt/openproject/assets:/var/openproject/assets"],
|
||||
environment: { "OPENPROJECT_HOST__NAME": "{{SUBDOMAIN}}.{{TLD}}", "OPENPROJECT_HTTPS": "true" }
|
||||
},
|
||||
subdomain: "projects",
|
||||
defaultPort: 8091,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Complete the initial setup wizard", "Create your first project", "Add team members and configure permissions"]
|
||||
},
|
||||
|
||||
"plane": {
|
||||
name: "Plane",
|
||||
description: "Open-source Jira alternative with issues, cycles, and modules",
|
||||
icon: "\u2708\uFE0F",
|
||||
category: "Productivity",
|
||||
popularity: 70,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "makeplane/plane-frontend:latest",
|
||||
ports: ["{{PORT}}:3000"],
|
||||
volumes: ["/opt/plane/data:/app/data"],
|
||||
environment: { "NEXT_PUBLIC_API_BASE_URL": "https://{{SUBDOMAIN}}.{{TLD}}/api" }
|
||||
},
|
||||
subdomain: "plane",
|
||||
defaultPort: 8092,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Plane requires multiple containers - use the recipe for full stack deploy", "Create an organization and invite team members", "Set up cycles and modules for project tracking"]
|
||||
},
|
||||
|
||||
"hedgedoc": {
|
||||
name: "HedgeDoc",
|
||||
description: "Collaborative markdown editor with real-time editing",
|
||||
icon: "\u{1F4DD}",
|
||||
category: "Productivity",
|
||||
popularity: 68,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "quay.io/hedgedoc/hedgedoc:latest",
|
||||
ports: ["{{PORT}}:3000"],
|
||||
volumes: ["/opt/hedgedoc/uploads:/hedgedoc/public/uploads"],
|
||||
environment: {
|
||||
"CMD_DOMAIN": "{{SUBDOMAIN}}.{{TLD}}",
|
||||
"CMD_URL_ADDPORT": "false",
|
||||
"CMD_PROTOCOL_USESSL": "true",
|
||||
"CMD_DB_URL": "sqlite:/hedgedoc/database.sqlite"
|
||||
}
|
||||
},
|
||||
subdomain: "notes",
|
||||
defaultPort: 8093,
|
||||
healthCheck: "/status",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Register an account or use guest mode", "Create collaborative markdown documents", "Share links for real-time co-editing"]
|
||||
},
|
||||
|
||||
"yacht": {
|
||||
name: "Yacht",
|
||||
description: "Web-based Docker container manager with template support",
|
||||
icon: "\u26F5",
|
||||
category: "Management",
|
||||
popularity: 65,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "selfhostedpro/yacht:latest",
|
||||
ports: ["{{PORT}}:8000"],
|
||||
volumes: ["/opt/yacht/config:/config", "/var/run/docker.sock:/var/run/docker.sock"],
|
||||
environment: { "PUID": "1000", "PGID": "1000" }
|
||||
},
|
||||
subdomain: "yacht",
|
||||
defaultPort: 8094,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'strip',
|
||||
setupInstructions: ["Set up admin account on first launch", "Add compose templates for one-click deployments", "Manage containers through the web interface"]
|
||||
},
|
||||
|
||||
"openvpn": {
|
||||
name: "OpenVPN",
|
||||
description: "Virtual private network server for secure remote access",
|
||||
icon: "\u{1F512}",
|
||||
category: "Security",
|
||||
popularity: 78,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "kylemanna/openvpn:latest",
|
||||
ports: ["{{PORT}}:1194/udp"],
|
||||
volumes: ["/opt/openvpn:/etc/openvpn"],
|
||||
environment: {},
|
||||
cap_add: ["NET_ADMIN"]
|
||||
},
|
||||
subdomain: "openvpn",
|
||||
defaultPort: 1194,
|
||||
healthCheck: null,
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Initialize the PKI: docker exec openvpn ovpn_initpki", "Generate client certificates for each device", "Download client configs and import into OpenVPN client"]
|
||||
},
|
||||
|
||||
"forgejo": {
|
||||
name: "Forgejo",
|
||||
description: "Self-hosted Git service - soft fork of Gitea with enhanced features",
|
||||
icon: "\u{1F527}",
|
||||
category: "Development",
|
||||
popularity: 73,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "codeberg.org/forgejo/forgejo:latest",
|
||||
ports: ["{{PORT}}:3000", "2222:22"],
|
||||
volumes: ["/opt/forgejo/data:/data", "/etc/localtime:/etc/localtime:ro"],
|
||||
environment: { "USER_UID": "1000", "USER_GID": "1000" }
|
||||
},
|
||||
subdomain: "forgejo",
|
||||
defaultPort: 8095,
|
||||
healthCheck: "/api/v1/version",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Configure database on first run", "Create admin account and disable open registration", "Migrate repos from GitHub/GitLab via the built-in migration tool", "Set up Actions for CI/CD pipelines"]
|
||||
},
|
||||
|
||||
"gitlab": {
|
||||
name: "GitLab",
|
||||
description: "Complete DevOps platform with Git, CI/CD, and project management",
|
||||
icon: "\u{1F99A}",
|
||||
category: "Development",
|
||||
popularity: 88,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "gitlab/gitlab-ce:latest",
|
||||
ports: ["{{PORT}}:80", "8443:443", "2223:22"],
|
||||
volumes: ["/opt/gitlab/config:/etc/gitlab", "/opt/gitlab/logs:/var/log/gitlab", "/opt/gitlab/data:/var/opt/gitlab"],
|
||||
environment: { "GITLAB_OMNIBUS_CONFIG": "external_url https://{{SUBDOMAIN}}.{{TLD}}" },
|
||||
shm_size: "256m"
|
||||
},
|
||||
subdomain: "gitlab",
|
||||
defaultPort: 8096,
|
||||
healthCheck: "/-/health",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Requires at least 4GB RAM and 2 CPU cores", "Wait 5-10 minutes for first boot", "Set root password on first login", "Configure runners for CI/CD pipelines"],
|
||||
resourceWarning: { minMemoryMB: 4096, minCpuCores: 2, message: "GitLab requires at least 4GB RAM. Deploying on a smaller server may cause instability." }
|
||||
},
|
||||
|
||||
"prometheus": {
|
||||
name: "Prometheus",
|
||||
description: "Monitoring system with time-series database and alerting",
|
||||
icon: "\u{1F525}",
|
||||
category: "Monitoring",
|
||||
popularity: 85,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "prom/prometheus:latest",
|
||||
ports: ["{{PORT}}:9090"],
|
||||
volumes: ["/opt/prometheus/data:/prometheus", "/opt/prometheus/config:/etc/prometheus"],
|
||||
environment: {},
|
||||
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle"]
|
||||
},
|
||||
subdomain: "prometheus",
|
||||
defaultPort: 9090,
|
||||
healthCheck: "/-/healthy",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Edit /opt/prometheus/config/prometheus.yml to add scrape targets", "Add DashCaddy /api/v1/metrics/prometheus as a scrape target", "Pair with Grafana for dashboards", "Configure alert rules"]
|
||||
},
|
||||
|
||||
"netdata": {
|
||||
name: "Netdata",
|
||||
description: "Real-time system metrics with zero configuration monitoring",
|
||||
icon: "\u{1F4C8}",
|
||||
category: "Monitoring",
|
||||
popularity: 82,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "netdata/netdata:latest",
|
||||
ports: ["{{PORT}}:19999"],
|
||||
volumes: ["/opt/netdata/config:/etc/netdata", "/opt/netdata/lib:/var/lib/netdata", "/opt/netdata/cache:/var/cache/netdata", "/proc:/host/proc:ro", "/sys:/host/sys:ro", "/etc/os-release:/host/etc/os-release:ro"],
|
||||
environment: { "NETDATA_CLAIM_URL": "https://app.netdata.cloud" },
|
||||
cap_add: ["SYS_PTRACE"],
|
||||
security_opt: ["apparmor:unconfined"]
|
||||
},
|
||||
subdomain: "netdata",
|
||||
defaultPort: 19999,
|
||||
healthCheck: "/api/v1/info",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Dashboard is immediately available with zero configuration", "Monitor CPU, RAM, disk, network, Docker in real-time", "Configure alarms for resource thresholds", "Optional: claim to Netdata Cloud for centralized monitoring"]
|
||||
},
|
||||
|
||||
"kibana": {
|
||||
name: "Kibana",
|
||||
description: "Visualization dashboard for Elasticsearch data analytics",
|
||||
icon: "\u{1F4C9}",
|
||||
category: "Monitoring",
|
||||
popularity: 70,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "kibana:8.12.0",
|
||||
ports: ["{{PORT}}:5601"],
|
||||
volumes: ["/opt/kibana/config:/usr/share/kibana/config"],
|
||||
environment: { "ELASTICSEARCH_HOSTS": "http://elasticsearch:9200" }
|
||||
},
|
||||
subdomain: "kibana",
|
||||
defaultPort: 5601,
|
||||
healthCheck: "/api/status",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Requires an Elasticsearch instance running first", "Consider deploying via the ELK Stack recipe", "Create index patterns for your data", "Build dashboards and visualizations"]
|
||||
},
|
||||
|
||||
"openhab": {
|
||||
name: "OpenHAB",
|
||||
description: "Open source home automation platform with broad device support",
|
||||
icon: "\u2699\uFE0F",
|
||||
category: "Home Automation",
|
||||
popularity: 72,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "openhab/openhab:latest",
|
||||
ports: ["{{PORT}}:8080", "8444:8443"],
|
||||
volumes: ["/opt/openhab/conf:/openhab/conf", "/opt/openhab/userdata:/openhab/userdata", "/opt/openhab/addons:/openhab/addons", "/etc/localtime:/etc/localtime:ro"],
|
||||
environment: { "OPENHAB_HTTP_PORT": "8080", "OPENHAB_HTTPS_PORT": "8443", "EXTRA_JAVA_OPTS": "-Duser.timezone={{TIMEZONE}}" }
|
||||
},
|
||||
subdomain: "openhab",
|
||||
defaultPort: 8097,
|
||||
healthCheck: "/start/index",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Choose your preferred package (Standard, Demo, or Minimal)", "Install bindings for your smart home devices", "Create items, sitemaps, and rules", "Access the Paper UI for configuration"]
|
||||
},
|
||||
|
||||
"bitwarden": {
|
||||
name: "Bitwarden",
|
||||
description: "Official Bitwarden password manager server (unified self-hosted)",
|
||||
icon: "\u{1F510}",
|
||||
category: "Security",
|
||||
popularity: 80,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "bitwarden/self-host:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: ["/opt/bitwarden/data:/etc/bitwarden"],
|
||||
environment: { "BW_DOMAIN": "{{SUBDOMAIN}}.{{TLD}}" }
|
||||
},
|
||||
subdomain: "bitwarden",
|
||||
defaultPort: 8098,
|
||||
healthCheck: "/health",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Complete the Bitwarden unified installation", "Generate installation ID and key at bitwarden.com/host", "Configure SMTP for email verification", "Note: Vaultwarden is recommended for smaller deployments"],
|
||||
resourceWarning: { minMemoryMB: 2048, message: "Official Bitwarden requires 2GB+ RAM. For lightweight deployments, use Vaultwarden instead." }
|
||||
},
|
||||
|
||||
"subsonic": {
|
||||
name: "Subsonic",
|
||||
description: "Web-based media streamer for music with wide client support",
|
||||
icon: "\u{1F3B5}",
|
||||
category: "Media",
|
||||
popularity: 60,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "linuxserver/subsonic:latest",
|
||||
ports: ["{{PORT}}:4040"],
|
||||
volumes: ["/opt/subsonic/config:/config", "/opt/subsonic/music:/music", "/opt/subsonic/podcasts:/podcasts", "/opt/subsonic/playlists:/playlists"],
|
||||
environment: { "PUID": "1000", "PGID": "1000", "TZ": "{{TIMEZONE}}" }
|
||||
},
|
||||
subdomain: "subsonic",
|
||||
defaultPort: 4040,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Set admin password on first launch", "Add music folders in Settings", "Install Subsonic-compatible apps on your devices", "Note: Airsonic Advanced is also available as a fully open-source alternative"],
|
||||
mediaMount: { required: true, containerPath: "/music", label: "Music Library", description: "Folder containing your music files", defaultPath: "/media/music" }
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
// Template categories for organization
|
||||
|
||||
@@ -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 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 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_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
|
||||
@@ -242,6 +243,11 @@ class ResourceMonitor extends EventEmitter {
|
||||
containerStats.history = containerStats.history.filter(s =>
|
||||
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() {
|
||||
try {
|
||||
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) {
|
||||
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 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_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);
|
||||
|
||||
class HealthChecker extends EventEmitter {
|
||||
@@ -217,7 +218,7 @@ class HealthChecker extends EventEmitter {
|
||||
statusCode: res.statusCode,
|
||||
message: healthy ? 'Service is healthy' : 'Service check failed',
|
||||
details: {
|
||||
headers: res.headers,
|
||||
headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix
|
||||
bodyLength: data.length
|
||||
}
|
||||
});
|
||||
@@ -285,6 +286,11 @@ class HealthChecker extends EventEmitter {
|
||||
}
|
||||
|
||||
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
|
||||
this.emit('status-check', status);
|
||||
@@ -565,6 +571,10 @@ class HealthChecker extends EventEmitter {
|
||||
this.history[serviceId] = this.history[serviceId].filter(h =>
|
||||
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() {
|
||||
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) {
|
||||
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -77,6 +77,7 @@ const bundles = {
|
||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||
// defined in globals.js (already in core.js).
|
||||
JS('share-modal.js'),
|
||||
JS('i18n.js'),
|
||||
],
|
||||
'onboarding.js': [
|
||||
JS('driver.min.js'),
|
||||
|
||||
@@ -652,6 +652,23 @@
|
||||
<!-- Will be filled dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Disk safety info: health checks accumulate data over time -->
|
||||
<div style="margin-top: 16px; padding: 14px 16px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 10%, transparent); border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||
<span style="font-size: 1.2rem; line-height: 1;">💾</span>
|
||||
<div>
|
||||
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage & Health-Check Data</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted); line-height: 1.45;">
|
||||
DashCaddy's health checks log response times, uptime history, and incidents for every monitored service.
|
||||
Over time this data accumulates and can consume significant disk space — especially on small VPS or
|
||||
SD-card installs. After setup, open <strong>Health → Configure → Global Settings</strong> to set a
|
||||
<strong>data retention period</strong> (default 30 days), adjust the <strong>polling interval</strong>,
|
||||
and configure a <strong>disk-usage warning threshold</strong> so you're alerted before storage runs low.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||
|
||||
@@ -34,6 +34,34 @@
|
||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Settings: retention, polling interval, disk-usage threshold -->
|
||||
<div id="health-global-settings" style="margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 style="margin: 0 0 4px;">🌍 Global Settings</h4>
|
||||
<p class="text-muted-sm" style="margin: 0 0 12px;">Applies to all health checks. Settings are stored locally in this browser.</p>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Data Retention (days)</label>
|
||||
<input type="number" id="health-setting-retention" value="30" min="1" max="3650" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Health history older than this is pruned.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-interval" value="30" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service is checked.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Disk-Usage Warning (%)</label>
|
||||
<input type="number" id="health-setting-disk-threshold" value="80" min="50" max="99" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Warn when disk usage exceeds this level.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<button id="health-global-save" class="btn-accent-solid">Save Global Settings</button>
|
||||
<button id="health-global-reset" class="btn-sm">Reset to Defaults</button>
|
||||
<span id="health-global-status" style="font-size: 0.8rem; color: var(--muted);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Health Check Form -->
|
||||
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
||||
@@ -103,6 +131,71 @@
|
||||
const formCancel = document.getElementById('health-form-cancel');
|
||||
const formSave = document.getElementById('health-form-save');
|
||||
|
||||
// ---- Global health settings (retention, polling interval, disk threshold) ----
|
||||
const HEALTH_SETTINGS_KEY = 'dashcaddy-health-settings';
|
||||
const HEALTH_DEFAULTS = { retentionDays: 30, pollingInterval: 30, diskUsageThreshold: 80 };
|
||||
const globalSaveBtn = document.getElementById('health-global-save');
|
||||
const globalResetBtn = document.getElementById('health-global-reset');
|
||||
const globalStatusSpan = document.getElementById('health-global-status');
|
||||
const retentionInput = document.getElementById('health-setting-retention');
|
||||
const intervalInput = document.getElementById('health-setting-interval');
|
||||
const diskThresholdInput = document.getElementById('health-setting-disk-threshold');
|
||||
|
||||
function loadHealthSettings() {
|
||||
try {
|
||||
const raw = safeGet(HEALTH_SETTINGS_KEY);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
return Object.assign({}, HEALTH_DEFAULTS, saved);
|
||||
} catch (_) {
|
||||
return Object.assign({}, HEALTH_DEFAULTS);
|
||||
}
|
||||
}
|
||||
|
||||
function applyHealthSettingsToUI() {
|
||||
const s = loadHealthSettings();
|
||||
if (retentionInput) retentionInput.value = s.retentionDays;
|
||||
if (intervalInput) intervalInput.value = s.pollingInterval;
|
||||
if (diskThresholdInput) diskThresholdInput.value = s.diskUsageThreshold;
|
||||
}
|
||||
|
||||
function saveHealthSettings() {
|
||||
const settings = {
|
||||
retentionDays: Math.max(1, Math.min(3650, parseInt(retentionInput?.value) || HEALTH_DEFAULTS.retentionDays)),
|
||||
pollingInterval: Math.max(5, Math.min(3600, parseInt(intervalInput?.value) || HEALTH_DEFAULTS.pollingInterval)),
|
||||
diskUsageThreshold: Math.max(50, Math.min(99, parseInt(diskThresholdInput?.value) || HEALTH_DEFAULTS.diskUsageThreshold))
|
||||
};
|
||||
try {
|
||||
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(settings));
|
||||
applyHealthSettingsToUI();
|
||||
if (globalStatusSpan) {
|
||||
globalStatusSpan.textContent = 'Saved ✓';
|
||||
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||
}
|
||||
if (typeof showNotification === 'function') showNotification('Global health settings saved', 'success');
|
||||
} catch (e) {
|
||||
if (globalStatusSpan) { globalStatusSpan.textContent = 'Save failed'; globalStatusSpan.style.color = 'var(--bad-fg)'; }
|
||||
if (typeof showNotification === 'function') showNotification('Failed to save settings: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetHealthSettings() {
|
||||
try {
|
||||
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(HEALTH_DEFAULTS));
|
||||
} catch (_) { /* ignore */ }
|
||||
applyHealthSettingsToUI();
|
||||
if (globalStatusSpan) {
|
||||
globalStatusSpan.textContent = 'Reset to defaults ✓';
|
||||
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
applyHealthSettingsToUI();
|
||||
globalSaveBtn?.addEventListener('click', saveHealthSettings);
|
||||
globalResetBtn?.addEventListener('click', resetHealthSettings);
|
||||
// ---- End global health settings ----
|
||||
|
||||
let editingId = null;
|
||||
|
||||
function uptimeColor(pct) {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* DC-077: i18n Frontend — Language selector and translation system
|
||||
*
|
||||
* Provides window.DCI18n.t(key) for the dashboard frontend.
|
||||
* Loads translations from /api/v1/i18n/translations/:lang
|
||||
* Language preference stored in localStorage.
|
||||
* Handles RTL for Arabic.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'dashcaddy-language';
|
||||
const DEFAULT_LANG = 'en';
|
||||
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'de', 'ar'];
|
||||
const LANG_NAMES = {
|
||||
en: 'English', es: 'Español', fr: 'Français', de: 'Deutsch', ar: 'العربية',
|
||||
};
|
||||
|
||||
let currentLang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG;
|
||||
let translations = {};
|
||||
let loaded = false;
|
||||
|
||||
async function loadTranslations(lang) {
|
||||
if (lang === DEFAULT_LANG) {
|
||||
translations = {}; // English is the default — no translation needed
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
translations = data.translations || {};
|
||||
loaded = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Failed to load translations for', lang, e);
|
||||
}
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (currentLang === DEFAULT_LANG) return key;
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||
currentLang = lang;
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
|
||||
// RTL handling
|
||||
const isRtl = lang === 'ar';
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
loadTranslations(lang).then(() => {
|
||||
applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
function applyTranslations() {
|
||||
// Apply translations to elements with data-i18n attributes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) {
|
||||
el.placeholder = translated;
|
||||
}
|
||||
});
|
||||
// Apply to titles
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) {
|
||||
el.title = translated;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createLanguageSelector() {
|
||||
// Find the top bar area to insert the selector
|
||||
// Look for the auth-settings area or the header actions
|
||||
const targetContainer = document.querySelector('.header-actions') ||
|
||||
document.querySelector('#auth-settings-btn')?.parentElement ||
|
||||
document.querySelector('.top-bar-actions');
|
||||
|
||||
if (!targetContainer) {
|
||||
// If we can't find a target, try to add it near the settings button
|
||||
const settingsBtn = document.getElementById('auth-settings-btn');
|
||||
if (settingsBtn && settingsBtn.parentElement) {
|
||||
return createDropdown(settingsBtn.parentElement);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return createDropdown(targetContainer);
|
||||
}
|
||||
|
||||
function createDropdown(container) {
|
||||
// Check if selector already exists
|
||||
if (document.getElementById('dc-lang-selector')) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'dc-lang-selector';
|
||||
wrapper.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'dc-lang-btn';
|
||||
btn.className = 'lang-selector-btn';
|
||||
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
|
||||
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
|
||||
btn.title = 'Language / Idioma / Langue / Sprache / اللغة';
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.id = 'dc-lang-dropdown';
|
||||
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 140px; overflow: hidden;';
|
||||
|
||||
SUPPORTED_LANGS.forEach(lang => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'lang-option';
|
||||
item.style.cssText = 'padding: 8px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-primary, #e0e0e0);';
|
||||
item.onmouseenter = () => item.style.background = 'var(--card-hover, rgba(255,255,255,0.05))';
|
||||
item.onmouseleave = () => item.style.background = 'transparent';
|
||||
|
||||
const flag = document.createElement('span');
|
||||
flag.textContent = lang === currentLang ? '✓' : '';
|
||||
flag.style.cssText = 'width: 16px; color: var(--ok-fg, #4ade80);';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = LANG_NAMES[lang];
|
||||
|
||||
item.appendChild(flag);
|
||||
item.appendChild(name);
|
||||
item.onclick = () => {
|
||||
setLanguage(lang);
|
||||
dropdown.style.display = 'none';
|
||||
// Update button text
|
||||
btn.querySelector('.lang-current').textContent = lang.toUpperCase();
|
||||
// Update checkmarks
|
||||
dropdown.querySelectorAll('.lang-option').forEach((opt, i) => {
|
||||
opt.querySelector('span').textContent = SUPPORTED_LANGS[i] === lang ? '✓' : '';
|
||||
});
|
||||
// Show notification
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Language: ${LANG_NAMES[lang]}`, 'info');
|
||||
}
|
||||
};
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrapper.contains(e.target)) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
wrapper.appendChild(dropdown);
|
||||
container.insertBefore(wrapper, container.firstChild);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
function init() {
|
||||
// Set initial RTL if needed
|
||||
if (currentLang === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = 'ar';
|
||||
}
|
||||
|
||||
// Create the language selector after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
|
||||
});
|
||||
} else {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||
|
||||
// Auto-init
|
||||
init();
|
||||
})();
|
||||
@@ -135,6 +135,22 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Disk-safety reminder (universal across all config types)
|
||||
html += `
|
||||
<div style="margin-top: 12px; padding: 12px 14px; border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12); background: color-mix(in srgb, var(--warn-fg, #f39c12) 8%, transparent);">
|
||||
<div style="display: flex; gap: 8px; align-items: flex-start;">
|
||||
<span style="font-size: 1.1rem;">💾</span>
|
||||
<div style="font-size: 0.85rem; line-height: 1.45;">
|
||||
<strong style="color: var(--warn-fg, #f39c12);">Disk-space tip:</strong>
|
||||
Health-check monitoring records uptime, response-time, and incident data continuously.
|
||||
By default DashCaddy keeps <strong>30 days</strong> of history and warns at <strong>80% disk usage</strong>.
|
||||
You can tune the retention period, polling interval, and disk threshold later under
|
||||
<strong>Health → Configure → Global Settings</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += '</div>';
|
||||
summaryContent.innerHTML = html;
|
||||
showStep('setup-step-summary');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
||||
const CACHE = 'dashcaddy-shell-c775f8444e';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||