Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7938cc76ec | ||
|
|
5abf385c7e |
No files matched your search
@@ -1,56 +0,0 @@
|
||||
# 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,6 +17,19 @@ 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 — form-driven UI in the Tools menu that consumes the `/api/v1/caddycode/{generate,validate,templates}` endpoints. Form fields (domain, upstream, TLS mode, behavior toggles, custom headers, DashCaddy SSO gate) → live Caddyfile preview with copy-to-clipboard. 5 preset templates. Frontend XSS protection + backend field sanitization (DC-070) for defense in depth. 19 unit tests.
|
||||
- **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`.
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -31,7 +31,7 @@ describe('DC-077: i18n system', () => {
|
||||
});
|
||||
|
||||
it('falls back to English for unsupported language', () => {
|
||||
expect(i18n.t('dashboard.title', 'xx')).toBe('Dashboard');
|
||||
expect(i18n.t('dashboard.title', 'zh')).toBe('Dashboard');
|
||||
});
|
||||
|
||||
it('falls back to key if not found in any language', () => {
|
||||
@@ -58,8 +58,8 @@ describe('DC-077: i18n system', () => {
|
||||
});
|
||||
|
||||
it('returns false for unsupported languages', () => {
|
||||
expect(i18n.isSupported('xx')).toBe(false);
|
||||
expect(i18n.isSupported('klingon')).toBe(false);
|
||||
expect(i18n.isSupported('zh')).toBe(false);
|
||||
expect(i18n.isSupported('ja')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,61 +81,14 @@ describe('DC-077: i18n system', () => {
|
||||
});
|
||||
|
||||
it('defaults to English for unsupported languages', () => {
|
||||
expect(i18n.detectLanguage('xx-XX,xx;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('klingon-KL,klingon;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('strips region codes before matching', () => {
|
||||
expect(i18n.detectLanguage('en-US,en;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('de-AT,de;q=0.9')).toBe('de');
|
||||
});
|
||||
|
||||
|
||||
it('respects equal q-values by order', () => {
|
||||
expect(i18n.detectLanguage('en;q=0.5,de;q=0.5')).toBe('en');
|
||||
});
|
||||
|
||||
it('excludes q=0 entries per RFC 7231', () => {
|
||||
expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr');
|
||||
});
|
||||
|
||||
it('serves default language when all entries have q=0 (intentional fallback)', () => {
|
||||
expect(i18n.detectLanguage('en;q=0,fr;q=0')).toBe('en');
|
||||
});
|
||||
|
||||
it('handles malformed q-values gracefully', () => {
|
||||
// 'abc' is not a valid q-value per RFC 7231 grammar, so it is treated as
|
||||
// "no q-value specified" — per the HTTP spec the default weight is q=1.0.
|
||||
expect(i18n.detectLanguage('en;q=abc,fr;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('accepts q=0 boundary (excludes entry)', () => {
|
||||
expect(i18n.detectLanguage('en;q=0,fr;q=0.9')).toBe('fr');
|
||||
});
|
||||
|
||||
it('accepts q=1 boundary', () => {
|
||||
expect(i18n.detectLanguage('en;q=1,fr;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('accepts q=1.0', () => {
|
||||
expect(i18n.detectLanguage('en;q=1.0,fr;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('accepts q=0.001 (lowest non-zero weight)', () => {
|
||||
expect(i18n.detectLanguage('en;q=0.001,fr;q=0.9')).toBe('fr');
|
||||
});
|
||||
|
||||
it('accepts q=0.999', () => {
|
||||
expect(i18n.detectLanguage('en;q=0.999,fr;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('rejects q=1.001 (RFC invalid) — defaults to 1.0', () => {
|
||||
expect(i18n.detectLanguage('en;q=1.001,fr;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('handles uppercase Q parameter', () => {
|
||||
expect(i18n.detectLanguage('en;Q=0.5,fr;q=0.9')).toBe('fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RTL support', () => {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,13 @@ function createI18nApp() {
|
||||
}
|
||||
|
||||
describe('DC-077: i18n Routes', () => {
|
||||
it('GET /i18n/languages returns 31 languages', async () => {
|
||||
it('GET /i18n/languages returns 5 languages', async () => {
|
||||
const app = createI18nApp();
|
||||
const res = await request(app).get('/api/v1/i18n/languages');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.languages).toHaveLength(31);
|
||||
expect(res.body.languages).toHaveLength(5);
|
||||
expect(res.body.default).toBe('en');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* DC-106: Reverse Proxy Visual Builder — pure-function unit tests.
|
||||
*
|
||||
* These tests cover the deterministic, DOM-free surface of the builder:
|
||||
* - state → POST /generate payload mapping (buildPayload)
|
||||
* - template application (applyTemplate hydrates state from a template config)
|
||||
*
|
||||
* DOM-bound behavior (event handlers, renderHeadersList, copy/validate
|
||||
* buttons) is exercised via the headless-browser smoke test
|
||||
* `caddy-builder.browser.smoke.test.js` which uses the running dev server.
|
||||
* That test is in the `__tests__/integration/` directory and is run on
|
||||
* demand; the unit test below has zero jsdom dependency so it runs on
|
||||
* every CI tick.
|
||||
*
|
||||
* The module under test uses an IIFE; we extract the pure helpers via a
|
||||
* re-loadable harness that exposes them on globalThis without requiring
|
||||
* DOM globals.
|
||||
*/
|
||||
|
||||
// --- DOM stub: minimal window/document/injectModal/escapeHtml shims ---
|
||||
// The caddy-builder.js IIFE needs window.injectModal, window.escapeHtml,
|
||||
// window.fetch, document.body.insertAdjacentHTML, and document.getElementById
|
||||
// at module-load time. We stub all of them with no-ops so the IIFE runs
|
||||
// without exploding — but no actual DOM rendering happens. That's fine for
|
||||
// testing the pure helpers, which only need `state`.
|
||||
|
||||
global.window = global.window || {};
|
||||
global.window.injectModal = () => {};
|
||||
global.window.escapeHtml = (text) => String(text ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
// Also expose as bare globals — the module's IIFE references `injectModal`
|
||||
// and `escapeHtml` as bare identifiers, so they need to be resolvable in
|
||||
// the eval scope.
|
||||
global.injectModal = global.window.injectModal;
|
||||
global.escapeHtml = global.window.escapeHtml;
|
||||
|
||||
// Tiny DOM shim — only what caddy-builder.js touches at load time.
|
||||
// Built in two passes to avoid the "Cannot access 'stubEl' before
|
||||
// initialization" TDZ trap (parentNode self-reference).
|
||||
function buildStubEl() {
|
||||
const el = {
|
||||
style: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
dataset: {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
insertAdjacentHTML() {},
|
||||
setAttribute() {},
|
||||
getAttribute() { return null; },
|
||||
dispatchEvent() {},
|
||||
focus() {},
|
||||
blur() {},
|
||||
set innerHTML(_) {},
|
||||
get innerHTML() { return ''; },
|
||||
set textContent(_) {},
|
||||
get textContent() { return ''; },
|
||||
set value(_) {},
|
||||
get value() { return ''; },
|
||||
set checked(_) {},
|
||||
get checked() { return false; },
|
||||
set disabled(_) {},
|
||||
get disabled() { return false; },
|
||||
children: [],
|
||||
parentNode: null,
|
||||
firstChild: null,
|
||||
};
|
||||
el.parentNode = el;
|
||||
el.firstChild = el;
|
||||
el.appendChild = function(child) {
|
||||
el.children.push(child);
|
||||
child.parentNode = el;
|
||||
return child;
|
||||
};
|
||||
el.querySelector = () => el;
|
||||
el.querySelectorAll = () => [];
|
||||
return el;
|
||||
}
|
||||
const stubEl = buildStubEl();
|
||||
|
||||
const elementById = new Map();
|
||||
function makeEl(id, tag) {
|
||||
const el = Object.create(stubEl);
|
||||
el.id = id;
|
||||
el.tagName = (tag || 'div').toUpperCase();
|
||||
el.children = [];
|
||||
el.parentNode = stubEl;
|
||||
el._children = [];
|
||||
el.appendChild = function(child) { el.children.push(child); child.parentNode = el; return child; };
|
||||
el.querySelector = function(sel) {
|
||||
// Very dumb: return first descendant whose tag matches the selector's tagname
|
||||
const m = sel.match(/^[a-z]+/);
|
||||
const tag = m ? m[0].toUpperCase() : null;
|
||||
function find(node) {
|
||||
if (tag && node.tagName === tag) return node;
|
||||
for (const c of (node.children || [])) {
|
||||
const r = find(c);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(el) || stubEl;
|
||||
};
|
||||
el.querySelectorAll = function() { return []; };
|
||||
elementById.set(id, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
global.document = {
|
||||
body: stubEl,
|
||||
getElementById: (id) => elementById.get(id) || makeEl(id),
|
||||
createElement: (tag) => makeEl('dyn-' + Math.random().toString(36).slice(2), tag),
|
||||
createRange: () => ({ selectNodeContents() {}, setStart() {}, setEnd() {}, collapse() {} }),
|
||||
};
|
||||
|
||||
global.Event = class Event {
|
||||
constructor(type) { this.type = type; }
|
||||
};
|
||||
global.navigator = { clipboard: { writeText: async () => {} } };
|
||||
global.fetch = jest.fn();
|
||||
global.setTimeout = setTimeout;
|
||||
global.clearTimeout = clearTimeout;
|
||||
|
||||
// --- Load the module under test ---
|
||||
function loadModule() {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const code = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', '..', 'status', 'js', 'caddy-builder.js'),
|
||||
'utf8'
|
||||
);
|
||||
// eslint-disable-next-line no-eval
|
||||
(0, eval)(code);
|
||||
return global.window.__caddyBuilder;
|
||||
}
|
||||
|
||||
// --- Tests ---------------------------------------------------------------
|
||||
|
||||
describe('DC-106: Caddy Visual Builder (pure)', () => {
|
||||
let builder;
|
||||
|
||||
beforeEach(() => {
|
||||
fetch.mockReset();
|
||||
// loadTemplates() runs at module-load time and hits /caddycode/templates.
|
||||
// Mock it to resolve with the 5 template presets so applyTemplate works.
|
||||
fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
templates: {
|
||||
'simple-proxy': { label: 'Simple reverse proxy', config: { domain: 'app.example.com', upstream: 'localhost:8080' } },
|
||||
'websocket-app': { label: 'WebSocket application', config: { domain: 'app.example.com', upstream: 'localhost:3000', websocket: true, compress: true } },
|
||||
'auth-gated': { label: 'Auth-gated (DashCaddy SSO)', config: { domain: 'app.example.com', upstream: 'localhost:8096', auth: true, authService: 'app' } },
|
||||
'cors-api': { label: 'API with CORS', config: { domain: 'api.example.com', upstream: 'localhost:3001', cors: true, compress: true } },
|
||||
'subdirectory': { label: 'Subdirectory proxy', config: { domain: 'example.com', upstream: 'localhost:8080', stripPrefix: '/app' } },
|
||||
},
|
||||
}),
|
||||
});
|
||||
elementById.clear();
|
||||
builder = loadModule();
|
||||
});
|
||||
|
||||
// Filter helper: count only POST /generate or POST /validate calls.
|
||||
const postCalls = () => fetch.mock.calls.filter(([url, opts]) =>
|
||||
String(url).includes('/caddycode/') && opts && opts.method === 'POST'
|
||||
);
|
||||
|
||||
describe('state defaults', () => {
|
||||
it('initializes with sensible defaults', () => {
|
||||
expect(builder.state.domain).toBe('blog.example.com');
|
||||
expect(builder.state.upstream).toBe('localhost:8080');
|
||||
expect(builder.state.upstreamProtocol).toBe('http');
|
||||
expect(builder.state.tls).toBe('auto');
|
||||
expect(builder.state.auth).toBe(false);
|
||||
expect(builder.state.compress).toBe(true);
|
||||
expect(builder.state.headers).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes the generated state via getCaddyfile()', () => {
|
||||
expect(builder.getCaddyfile()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPayload', () => {
|
||||
it('maps state → POST /generate payload (happy path)', () => {
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:3000';
|
||||
builder.state.websocket = true;
|
||||
builder.state.cors = true;
|
||||
builder.state.headers = [{ key: 'X-Forwarded-For', value: '{remote_host}' }];
|
||||
const payload = builder.buildPayload();
|
||||
expect(payload).toEqual({
|
||||
domain: 'app.example.com',
|
||||
upstream: 'localhost:3000',
|
||||
upstreamProtocol: 'http',
|
||||
tls: 'auto',
|
||||
auth: false,
|
||||
authService: null,
|
||||
websocket: true,
|
||||
cors: true,
|
||||
compress: true,
|
||||
stripPrefix: null,
|
||||
redirectToHttps: true,
|
||||
headers: { 'X-Forwarded-For': '{remote_host}' },
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace on domain / upstream / stripPrefix', () => {
|
||||
builder.state.domain = ' app.example.com ';
|
||||
builder.state.upstream = '\tlocalhost:8080\n';
|
||||
builder.state.stripPrefix = ' /api ';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.domain).toBe('app.example.com');
|
||||
expect(p.upstream).toBe('localhost:8080');
|
||||
expect(p.stripPrefix).toBe('/api');
|
||||
});
|
||||
|
||||
it('drops blank header keys (only headers with non-blank keys are sent)', () => {
|
||||
builder.state.headers = [
|
||||
{ key: 'X-Real-IP', value: '{remote_host}' },
|
||||
{ key: '', value: 'ignored' },
|
||||
{ key: ' ', value: 'also ignored' },
|
||||
];
|
||||
const p = builder.buildPayload();
|
||||
expect(p.headers).toEqual({ 'X-Real-IP': '{remote_host}' });
|
||||
});
|
||||
|
||||
it('nullifies authService when auth is off (security: never leaks auth_id without auth=true)', () => {
|
||||
builder.state.auth = false;
|
||||
builder.state.authService = 'leftover';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.authService).toBe(null);
|
||||
});
|
||||
|
||||
it('passes authService when auth is on', () => {
|
||||
builder.state.auth = true;
|
||||
builder.state.authService = 'blog';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.authService).toBe('blog');
|
||||
});
|
||||
|
||||
it('nullifies stripPrefix when blank', () => {
|
||||
builder.state.stripPrefix = '';
|
||||
const p = builder.buildPayload();
|
||||
expect(p.stripPrefix).toBe(null);
|
||||
});
|
||||
|
||||
it('sends all boolean fields with explicit values (no undefined)', () => {
|
||||
const p = builder.buildPayload();
|
||||
expect(typeof p.websocket).toBe('boolean');
|
||||
expect(typeof p.cors).toBe('boolean');
|
||||
expect(typeof p.compress).toBe('boolean');
|
||||
expect(typeof p.redirectToHttps).toBe('boolean');
|
||||
expect(typeof p.auth).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTemplate', () => {
|
||||
it('hydrates state from an auth-gated template', () => {
|
||||
builder.applyTemplate('auth-gated');
|
||||
expect(builder.state.auth).toBe(true);
|
||||
expect(builder.state.authService).toBe('app');
|
||||
expect(builder.state.upstream).toBe('localhost:8096');
|
||||
});
|
||||
|
||||
it('hydrates state from a cors-api template', () => {
|
||||
builder.applyTemplate('cors-api');
|
||||
expect(builder.state.cors).toBe(true);
|
||||
expect(builder.state.compress).toBe(true);
|
||||
expect(builder.state.upstream).toBe('localhost:3001');
|
||||
});
|
||||
|
||||
it('does nothing for unknown template id', () => {
|
||||
const before = JSON.stringify(builder.state);
|
||||
builder.applyTemplate('does-not-exist');
|
||||
const after = JSON.stringify(builder.state);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch integration (mocked)', () => {
|
||||
it('generate() POSTs to /caddycode/generate with correct headers', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
caddyfile: 'app.example.com {\n reverse_proxy localhost:8080\n}',
|
||||
}),
|
||||
});
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await builder.generate();
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/caddycode/generate', expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('generate() stores the returned caddyfile on success', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
caddyfile: 'app.example.com {\n reverse_proxy localhost:8080\n}',
|
||||
}),
|
||||
});
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await builder.generate();
|
||||
expect(builder.getCaddyfile()).toContain('reverse_proxy localhost:8080');
|
||||
});
|
||||
|
||||
it('generate() captures 400 errors without throwing', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: 'Invalid configuration',
|
||||
errors: ['upstream must be host:port'],
|
||||
}),
|
||||
});
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'bad';
|
||||
await expect(builder.generate()).resolves.toBeUndefined();
|
||||
expect(builder.getCaddyfile()).toBe('');
|
||||
});
|
||||
|
||||
it('generate() handles network failures gracefully', async () => {
|
||||
fetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await expect(builder.generate()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('generate() short-circuits when domain missing', async () => {
|
||||
builder.state.domain = '';
|
||||
builder.state.upstream = 'localhost:8080';
|
||||
await builder.generate();
|
||||
// loadTemplates() (run at module load) makes a GET to /templates; we
|
||||
// only care that generate() didn't POST /generate. Filter to POST.
|
||||
expect(postCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generate() short-circuits when upstream missing', async () => {
|
||||
builder.state.domain = 'app.example.com';
|
||||
builder.state.upstream = '';
|
||||
await builder.generate();
|
||||
expect(postCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS protection (escapeHtml integration)', () => {
|
||||
it('escapes user-typed values when headers would be rendered', () => {
|
||||
// The caddy-builder.js module calls escapeHtml() in renderHeadersList
|
||||
// to attribute-escape header keys/values before innerHTML injection.
|
||||
// Verify the global escapeHtml contract the module depends on.
|
||||
const malicious = '<script>alert(1)</script>"&<>\'onerror=x';
|
||||
const escaped = global.escapeHtml(malicious);
|
||||
expect(escaped).not.toContain('<script>');
|
||||
expect(escaped).not.toContain('"');
|
||||
expect(escaped).toContain('<script>');
|
||||
expect(escaped).toContain('"');
|
||||
expect(escaped).toContain('&');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* 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: 'jellyfin', reason: 'Free open-source alternative to Plex, no premium features locked' },
|
||||
{ app: 'emby', reason: 'Media server with live TV and parental controls' },
|
||||
{ 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?',
|
||||
disclaimer: 'DashCaddy provides deployment tools only. Users are responsible for complying with all applicable copyright and intellectual property laws. Always stream content you own or have rights to access.',
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 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;
|
||||
@@ -1,98 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// GET current disk settings + actual disk usage
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const settings = {
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000'),
|
||||
healthMaxEntries: parseInt(process.env.HEALTH_MAX_ENTRIES || '500'),
|
||||
healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '14'),
|
||||
statsMaxEntries: parseInt(process.env.CONTAINER_STATS_MAX_ENTRIES || '500'),
|
||||
auditMaxEntries: parseInt(process.env.AUDIT_MAX_ENTRIES || '1000'),
|
||||
backupMaxStorageBytes: parseInt(process.env.BACKUP_MAX_STORAGE_BYTES || '0'),
|
||||
};
|
||||
|
||||
// Get actual disk usage
|
||||
let diskUsage = { total: 0, used: 0, free: 0, dataDirSize: 0 };
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const dfOut = execSync("df -B1 /opt/dashcaddy/dashcaddy-api/data 2>/dev/null || df -B1 / 2>/dev/null").toString().trim().split('\n');
|
||||
if (dfOut.length > 1) {
|
||||
const parts = dfOut[1].split(/\s+/);
|
||||
diskUsage.total = parseInt(parts[1]) || 0;
|
||||
diskUsage.used = parseInt(parts[2]) || 0;
|
||||
diskUsage.free = parseInt(parts[3]) || 0;
|
||||
}
|
||||
const duOut = execSync("du -sb /opt/dashcaddy/dashcaddy-api/data 2>/dev/null || echo 0").toString().trim().split(/\s+/);
|
||||
diskUsage.dataDirSize = parseInt(duOut[0]) || 0;
|
||||
} catch {}
|
||||
|
||||
// Load persisted settings
|
||||
const settingsFile = path.join(path.dirname(require('../config/paths').configFile), 'disk-settings.json');
|
||||
let persisted = {};
|
||||
try { persisted = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
||||
|
||||
res.json({ success: true, current: { ...settings, ...persisted }, diskUsage });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST update settings
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { healthInterval, healthMaxEntries, healthRetentionDays, statsMaxEntries, auditMaxEntries } = req.body;
|
||||
const updates = {};
|
||||
|
||||
if (healthInterval !== undefined) { updates.healthCheckInterval = parseInt(healthInterval); process.env.HEALTH_CHECK_INTERVAL = String(healthInterval); }
|
||||
if (healthMaxEntries !== undefined) { updates.healthMaxEntries = parseInt(healthMaxEntries); process.env.HEALTH_MAX_ENTRIES = String(healthMaxEntries); }
|
||||
if (healthRetentionDays !== undefined) { updates.healthRetentionDays = parseInt(healthRetentionDays); process.env.HEALTH_HISTORY_RETENTION = String(healthRetentionDays); }
|
||||
if (statsMaxEntries !== undefined) { updates.statsMaxEntries = parseInt(statsMaxEntries); process.env.CONTAINER_STATS_MAX_ENTRIES = String(statsMaxEntries); }
|
||||
if (auditMaxEntries !== undefined) { updates.auditMaxEntries = parseInt(auditMaxEntries); process.env.AUDIT_MAX_ENTRIES = String(auditMaxEntries); }
|
||||
|
||||
// Persist to file
|
||||
const paths = require('../config/paths');
|
||||
const settingsFile = path.join(path.dirname(paths.configFile), 'disk-settings.json');
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ ...existing, ...updates }, null, 2));
|
||||
|
||||
res.json({ success: true, updated: updates, message: 'Settings saved. Some changes apply on next container restart.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST trigger immediate cleanup
|
||||
router.post('/cleanup', async (req, res) => {
|
||||
try {
|
||||
const results = { cleaned: {} };
|
||||
|
||||
// Clean health history
|
||||
try {
|
||||
const healthChecker = require('../monitoring/health-checker');
|
||||
if (healthChecker.instance && healthChecker.instance.cleanupHistory) {
|
||||
healthChecker.instance.cleanupHistory();
|
||||
results.cleaned.healthHistory = 'Cleaned old entries';
|
||||
}
|
||||
} catch (e) { results.cleaned.healthHistory = 'Skipped: ' + e.message; }
|
||||
|
||||
// Clean container stats
|
||||
try {
|
||||
const resourceMonitor = require('../managers/resource-monitor');
|
||||
if (resourceMonitor.instance && resourceMonitor.instance.cleanupOldStats) {
|
||||
resourceMonitor.instance.cleanupOldStats();
|
||||
results.cleaned.containerStats = 'Cleaned old entries';
|
||||
}
|
||||
} catch (e) { results.cleaned.containerStats = 'Skipped: ' + e.message; }
|
||||
|
||||
res.json({ success: true, results });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,153 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -28,7 +28,6 @@ const auditLogger = require('./security/audit-logger');
|
||||
const portLockManager = require('./managers/port-lock-manager');
|
||||
const resourceMonitor = require('./managers/resource-monitor');
|
||||
const backupManager = require('./utilities/backup-manager');
|
||||
require("./utilities/nesting-guard")();
|
||||
const healthChecker = require('./monitoring/health-checker');
|
||||
const updateManager = require('./managers/update-manager');
|
||||
const selfUpdater = require('./docker/self-updater');
|
||||
@@ -94,9 +93,6 @@ const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const securityRoutes = require('../routes/security');
|
||||
const diskSettingsRoutes = require('../routes/disk-settings');
|
||||
const aiIntentRoutes = require('../routes/ai-intent');
|
||||
const logInsightsRoutes = require('../routes/log-insights');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
@@ -757,22 +753,6 @@ async function createApp() {
|
||||
apiRouter.use('/security', securityRoutes({
|
||||
log: ctx.log,
|
||||
}));
|
||||
|
||||
// Log Insights — plain English activity summary + safe log disposal
|
||||
apiRouter.use('/disk-settings', diskSettingsRoutes);
|
||||
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));
|
||||
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({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
|
||||
@@ -1764,47 +1764,6 @@ const APP_TEMPLATES = {
|
||||
]
|
||||
},
|
||||
|
||||
"vintage-radio": {
|
||||
name: "Vintage Stereo",
|
||||
description: "Glass-front console stereo that tunes curated real internet stations (SomaFM, KEXP, Radio Paradise, and more) through a beautiful analog UI",
|
||||
icon: "📻",
|
||||
category: "Media",
|
||||
popularity: 72,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "nginx:alpine",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [
|
||||
"/opt/vintage-radio/web:/usr/share/nginx/html:ro"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "radio",
|
||||
defaultPort: 8090,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
preInstall: {
|
||||
description: "Materialize the bundled static assets into /opt/vintage-radio/web before starting the container.",
|
||||
script: "vintage-radio-install.sh"
|
||||
},
|
||||
features: [
|
||||
"Glass-front console stereo UI with wooden end caps and brushed-metal faceplate",
|
||||
"Tunable analog slide-rule dial with click-stop detents and red cursor flag",
|
||||
"Twin glowing VU meters with smooth needle animation while powered",
|
||||
"Power / Mode / Mute knobs, vertical volume slider, signal-strength LED",
|
||||
"MODE knob filters stations by genre (ALL / AMBIENT / ROCK / MIXED)",
|
||||
"18 curated real internet-radio streams (SomaFM, KEXP, Radio Paradise, Space Station Soma, Mission Control, and more)"
|
||||
],
|
||||
setupInstructions: [
|
||||
"Run `bash /usr/local/bin/vintage-radio-install.sh` once before starting the container — copies the bundled web assets (index.html, radio.css, radio.js, stations.json) from the DashCaddy repo (dashcaddy-api/static-sites/vintage-radio/web) into /opt/vintage-radio/web",
|
||||
"Open radio.sami (or your configured subdomain)",
|
||||
"Press the PWR knob, drag the dial or click a station card",
|
||||
"Cycle the MODE knob to filter by genre (ALL / AMBIENT / ROCK / MIXED)",
|
||||
"To add stations, edit /opt/vintage-radio/web/stations.json on the host and restart the container"
|
||||
],
|
||||
tags: ["radio", "music", "streaming", "audio", "vintage", "retro", "media"]
|
||||
},
|
||||
|
||||
"airsonic": {
|
||||
name: "Airsonic Advanced",
|
||||
description: "Free web-based media streamer",
|
||||
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
@@ -243,11 +242,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -626,7 +620,7 @@ class ResourceMonitor extends EventEmitter {
|
||||
saveStats() {
|
||||
try {
|
||||
const data = Object.fromEntries(this.stats);
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data)); // Compact JSON to reduce file size
|
||||
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
log.error('monitor', error, { operation: 'saveStats' });
|
||||
}
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
/**
|
||||
* 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,7 +30,6 @@ 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 {
|
||||
@@ -218,7 +217,7 @@ class HealthChecker extends EventEmitter {
|
||||
statusCode: res.statusCode,
|
||||
message: healthy ? 'Service is healthy' : 'Service check failed',
|
||||
details: {
|
||||
headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix
|
||||
headers: res.headers,
|
||||
bodyLength: data.length
|
||||
}
|
||||
});
|
||||
@@ -286,11 +285,6 @@ 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);
|
||||
@@ -571,10 +565,6 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +616,7 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
saveHistory() {
|
||||
try {
|
||||
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history)); // Compact JSON (no pretty-print) to reduce file size
|
||||
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history, null, 2));
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const KNOWN_KEYS = [
|
||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||
'customLogoDark', 'customLogoLight', 'language'
|
||||
'customLogoDark', 'customLogoLight'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,573 +1,264 @@
|
||||
/**
|
||||
* DashCaddy Internationalization (i18n) — 31 languages
|
||||
* DC-077: Internationalization (i18n) framework for DashCaddy
|
||||
*
|
||||
* Translations for dashboard UI and API error messages.
|
||||
* Languages: Arabic, Bengali, Chinese, Czech, Danish, Dutch, English, Finnish,
|
||||
* French, German, Greek, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean,
|
||||
* Malay, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Spanish,
|
||||
* Swedish, Thai, Turkish, Ukrainian, Urdu, Vietnamese.
|
||||
* Lightweight translation system for the dashboard frontend and API responses.
|
||||
* Supports multiple languages via JSON translation files loaded on demand.
|
||||
*
|
||||
* No Hebrew — per project policy.
|
||||
* Languages are stored in /assets/i18n/{lang}.json
|
||||
* Default language is 'en' (English).
|
||||
*
|
||||
* Usage in frontend JS:
|
||||
* const { t, setLanguage, getLanguage } = window.DCI18n;
|
||||
* document.querySelector('.title').textContent = t('dashboard.title');
|
||||
*
|
||||
* Usage in API responses:
|
||||
* const i18n = require('./i18n');
|
||||
* const msg = i18n.t('error.container_not_found', req.lang || 'en');
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Built-in translations (loaded synchronously at startup)
|
||||
const TRANSLATIONS = {
|
||||
en: { // 🇬🇧 English
|
||||
'dashboard.title': 'Dashboard', 'dashboard.services': 'Services', 'dashboard.containers': 'Containers',
|
||||
'dashboard.health': 'Health', 'dashboard.settings': 'Settings', 'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Monitoring', 'dashboard.security': 'Security',
|
||||
'service.status.healthy': 'Healthy', 'service.status.degraded': 'Degraded', 'service.status.down': 'Down',
|
||||
'service.status.unknown': 'Unknown', 'service.status.pending': 'Pending',
|
||||
'action.start': 'Start', 'action.stop': 'Stop', 'action.restart': 'Restart', 'action.delete': 'Delete',
|
||||
'action.update': 'Update', 'action.deploy': 'Deploy', 'action.save': 'Save', 'action.cancel': 'Cancel',
|
||||
en: {
|
||||
'dashboard.title': 'Dashboard',
|
||||
'dashboard.services': 'Services',
|
||||
'dashboard.containers': 'Containers',
|
||||
'dashboard.health': 'Health',
|
||||
'dashboard.settings': 'Settings',
|
||||
'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Monitoring',
|
||||
'dashboard.security': 'Security',
|
||||
|
||||
'service.status.healthy': 'Healthy',
|
||||
'service.status.degraded': 'Degraded',
|
||||
'service.status.down': 'Down',
|
||||
'service.status.unknown': 'Unknown',
|
||||
'service.status.pending': 'Pending',
|
||||
|
||||
'action.start': 'Start',
|
||||
'action.stop': 'Stop',
|
||||
'action.restart': 'Restart',
|
||||
'action.delete': 'Delete',
|
||||
'action.update': 'Update',
|
||||
'action.deploy': 'Deploy',
|
||||
'action.save': 'Save',
|
||||
'action.cancel': 'Cancel',
|
||||
'action.confirm': 'Confirm',
|
||||
'error.not_found': 'Resource not found', 'error.unauthorized': 'Unauthorized', 'error.forbidden': 'Forbidden',
|
||||
'error.rate_limited': 'Too many requests', 'error.internal': 'Internal server error',
|
||||
'error.container_not_found': 'Container not found', 'error.service_not_found': 'Service not found',
|
||||
'error.invalid_input': 'Invalid input', 'error.docker_unreachable': 'Docker daemon is not reachable',
|
||||
|
||||
'error.not_found': 'Resource not found',
|
||||
'error.unauthorized': 'Unauthorized',
|
||||
'error.forbidden': 'Forbidden',
|
||||
'error.rate_limited': 'Too many requests',
|
||||
'error.internal': 'Internal server error',
|
||||
'error.container_not_found': 'Container not found',
|
||||
'error.service_not_found': 'Service not found',
|
||||
'error.invalid_input': 'Invalid input',
|
||||
'error.docker_unreachable': 'Docker daemon is not reachable',
|
||||
'error.disk_full': 'Disk space is critically low',
|
||||
},
|
||||
ar: { // 🇸🇦 العربية
|
||||
'dashboard.title': 'لوحة التحكم', 'dashboard.services': 'الخدمات', 'dashboard.containers': 'الحاويات',
|
||||
'dashboard.health': 'الصحة', 'dashboard.settings': 'الإعدادات', 'dashboard.backups': 'النسخ الاحتياطية',
|
||||
'dashboard.monitoring': 'المراقبة', 'dashboard.security': 'الأمان',
|
||||
'service.status.healthy': 'سليم', 'service.status.degraded': 'متدهور', 'service.status.down': 'متوقف',
|
||||
'service.status.unknown': 'غير معروف', 'service.status.pending': 'قيد الانتظار',
|
||||
'action.start': 'تشغيل', 'action.stop': 'إيقاف', 'action.restart': 'إعادة تشغيل', 'action.delete': 'حذف',
|
||||
'action.update': 'تحديث', 'action.deploy': 'نشر', 'action.save': 'حفظ', 'action.cancel': 'إلغاء',
|
||||
'action.confirm': 'تأكيد',
|
||||
'error.not_found': 'المورد غير موجود', 'error.unauthorized': 'غير مصرح', 'error.forbidden': 'محظور',
|
||||
'error.rate_limited': 'طلبات كثيرة جداً', 'error.internal': 'خطأ داخلي في الخادم',
|
||||
'error.container_not_found': 'الحاوية غير موجودة', 'error.service_not_found': 'الخدمة غير موجودة',
|
||||
'error.invalid_input': 'إدخال غير صالح', 'error.docker_unreachable': 'لا يمكن الوصول إلى Docker',
|
||||
'error.disk_full': 'مساحة القرص منخفضة بشكل حرج',
|
||||
},
|
||||
bn: { // 🇧🇩 বাংলা
|
||||
'dashboard.title': 'ড্যাশবোর্ড', 'dashboard.services': 'পরিষেবা', 'dashboard.containers': 'কন্টেইনার',
|
||||
'dashboard.health': 'স্বাস্থ্য', 'dashboard.settings': 'সেটিংস', 'dashboard.backups': 'ব্যাকআপ',
|
||||
'dashboard.monitoring': 'নিরীক্ষণ', 'dashboard.security': 'নিরাপত্তা',
|
||||
'service.status.healthy': 'সুস্থ', 'service.status.degraded': 'অবনমিত', 'service.status.down': 'বন্ধ',
|
||||
'service.status.unknown': 'অজানা', 'service.status.pending': 'মুলতুবি',
|
||||
'action.start': 'শুরু', 'action.stop': 'বন্ধ', 'action.restart': 'পুনরায় চালু', 'action.delete': 'মুছুন',
|
||||
'action.update': 'আপডেট', 'action.deploy': 'স্থাপন', 'action.save': 'সংরক্ষণ', 'action.cancel': 'বাতিল',
|
||||
'action.confirm': 'নিশ্চিত করুন',
|
||||
'error.not_found': 'সম্পদ পাওয়া যায়নি', 'error.unauthorized': 'অননুমোদিত', 'error.forbidden': 'নিষিদ্ধ',
|
||||
'error.rate_limited': 'অনেক বেশি অনুরোধ', 'error.internal': 'অভ্যন্তরীণ সার্ভার ত্রুটি',
|
||||
'error.container_not_found': 'কন্টেইনার পাওয়া যায়নি', 'error.service_not_found': 'পরিষেবা পাওয়া যায়নি',
|
||||
'error.invalid_input': 'অবৈধ ইনপুট', 'error.docker_unreachable': 'Docker ডেমনে পৌঁছানো যাচ্ছে না',
|
||||
'error.disk_full': 'ডিস্ক স্থান সংকটজনকভাবে কম',
|
||||
},
|
||||
cs: { // 🇨🇿 Čeština
|
||||
'dashboard.title': 'Nástěnka', 'dashboard.services': 'Služby', 'dashboard.containers': 'Kontejnery',
|
||||
'dashboard.health': 'Stav', 'dashboard.settings': 'Nastavení', 'dashboard.backups': 'Zálohy',
|
||||
'dashboard.monitoring': 'Sledování', 'dashboard.security': 'Zabezpečení',
|
||||
'service.status.healthy': 'Zdravý', 'service.status.degraded': 'Zhoršený', 'service.status.down': 'Nedostupný',
|
||||
'service.status.unknown': 'Neznámý', 'service.status.pending': 'Čeká',
|
||||
'action.start': 'Spustit', 'action.stop': 'Zastavit', 'action.restart': 'Restartovat', 'action.delete': 'Smazat',
|
||||
'action.update': 'Aktualizovat', 'action.deploy': 'Nasadit', 'action.save': 'Uložit', 'action.cancel': 'Zrušit',
|
||||
'action.confirm': 'Potvrdit',
|
||||
'error.not_found': 'Zdroj nenalezen', 'error.unauthorized': 'Neoprávněno', 'error.forbidden': 'Zakázáno',
|
||||
'error.rate_limited': 'Příliš mnoho požadavků', 'error.internal': 'Interní chyba serveru',
|
||||
'error.container_not_found': 'Kontejner nenalezen', 'error.service_not_found': 'Služba nenalezena',
|
||||
'error.invalid_input': 'Neplatný vstup', 'error.docker_unreachable': 'Docker daemon není dostupný',
|
||||
'error.disk_full': 'Místo na disku je kriticky nízké',
|
||||
},
|
||||
da: { // 🇩🇰 Dansk
|
||||
'dashboard.title': 'Instrumentbræt', 'dashboard.services': 'Tjenester', 'dashboard.containers': 'Containere',
|
||||
'dashboard.health': 'Sundhed', 'dashboard.settings': 'Indstillinger', 'dashboard.backups': 'Sikkerhedskopier',
|
||||
'dashboard.monitoring': 'Overvågning', 'dashboard.security': 'Sikkerhed',
|
||||
'service.status.healthy': 'Sund', 'service.status.degraded': 'Forringet', 'service.status.down': 'Nede',
|
||||
'service.status.unknown': 'Ukendt', 'service.status.pending': 'Afventer',
|
||||
'action.start': 'Start', 'action.stop': 'Stop', 'action.restart': 'Genstart', 'action.delete': 'Slet',
|
||||
'action.update': 'Opdater', 'action.deploy': 'Udrul', 'action.save': 'Gem', 'action.cancel': 'Annuller',
|
||||
'action.confirm': 'Bekræft',
|
||||
'error.not_found': 'Ressource ikke fundet', 'error.unauthorized': 'Ikke autoriseret', 'error.forbidden': 'Forbudt',
|
||||
'error.rate_limited': 'For mange anmodninger', 'error.internal': 'Intern serverfejl',
|
||||
'error.container_not_found': 'Container ikke fundet', 'error.service_not_found': 'Tjeneste ikke fundet',
|
||||
'error.invalid_input': 'Ugyldigt input', 'error.docker_unreachable': 'Docker-daemon er ikke tilgængelig',
|
||||
'error.disk_full': 'Diskpladsen er kritisk lav',
|
||||
},
|
||||
de: { // 🇩🇪 Deutsch
|
||||
'dashboard.title': 'Dashboard', 'dashboard.services': 'Dienste', 'dashboard.containers': 'Container',
|
||||
'dashboard.health': 'Zustand', 'dashboard.settings': 'Einstellungen', 'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Überwachung', 'dashboard.security': 'Sicherheit',
|
||||
'service.status.healthy': 'Gesund', 'service.status.degraded': 'Beeinträchtigt', 'service.status.down': 'Ausgefallen',
|
||||
'service.status.unknown': 'Unbekannt', 'service.status.pending': 'Ausstehend',
|
||||
'action.start': 'Starten', 'action.stop': 'Stopp', 'action.restart': 'Neustart', 'action.delete': 'Löschen',
|
||||
'action.update': 'Aktualisieren', 'action.deploy': 'Bereitstellen', 'action.save': 'Speichern', 'action.cancel': 'Abbrechen',
|
||||
'action.confirm': 'Bestätigen',
|
||||
'error.not_found': 'Ressource nicht gefunden', 'error.unauthorized': 'Nicht autorisiert', 'error.forbidden': 'Verboten',
|
||||
'error.rate_limited': 'Zu viele Anfragen', 'error.internal': 'Interner Serverfehler',
|
||||
'error.container_not_found': 'Container nicht gefunden', 'error.service_not_found': 'Dienst nicht gefunden',
|
||||
'error.invalid_input': 'Ungültige Eingabe', 'error.docker_unreachable': 'Docker-Daemon ist nicht erreichbar',
|
||||
'error.disk_full': 'Speicherplatz kritisch niedrig',
|
||||
},
|
||||
el: { // 🇬🇷 Ελληνικά
|
||||
'dashboard.title': 'Πίνακας ελέγχου', 'dashboard.services': 'Υπηρεσίες', 'dashboard.containers': 'Κοντέινερ',
|
||||
'dashboard.health': 'Υγεία', 'dashboard.settings': 'Ρυθμίσεις', 'dashboard.backups': 'Αντίγραφα ασφαλείας',
|
||||
'dashboard.monitoring': 'Παρακολούθηση', 'dashboard.security': 'Ασφάλεια',
|
||||
'service.status.healthy': 'Υγιής', 'service.status.degraded': 'Υποβαθμισμένος', 'service.status.down': 'Κάτω',
|
||||
'service.status.unknown': 'Άγνωστος', 'service.status.pending': 'Εκκρεμής',
|
||||
'action.start': 'Έναρξη', 'action.stop': 'Διακοπή', 'action.restart': 'Επανεκκίνηση', 'action.delete': 'Διαγραφή',
|
||||
'action.update': 'Ενημέρωση', 'action.deploy': 'Ανάπτυξη', 'action.save': 'Αποθήκευση', 'action.cancel': 'Ακύρωση',
|
||||
'action.confirm': 'Επιβεβαίωση',
|
||||
'error.not_found': 'Ο πόρος δεν βρέθηκε', 'error.unauthorized': 'Μη εξουσιοδοτημένος', 'error.forbidden': 'Απαγορευμένο',
|
||||
'error.rate_limited': 'Πάρα πολλά αιτήματα', 'error.internal': 'Εσωτερικό σφάλμα διακομιστή',
|
||||
'error.container_not_found': 'Το κοντέινερ δεν βρέθηκε', 'error.service_not_found': 'Η υπηρεσία δεν βρέθηκε',
|
||||
'error.invalid_input': 'Μη έγκυρη είσοδος', 'error.docker_unreachable': 'Ο δαίμονας Docker δεν είναι προσβάσιμος',
|
||||
'error.disk_full': 'Ο χώρος δίσκου είναι κρίσιμα χαμηλός',
|
||||
},
|
||||
es: { // 🇪🇸 Español
|
||||
'dashboard.title': 'Panel de control', 'dashboard.services': 'Servicios', 'dashboard.containers': 'Contenedores',
|
||||
'dashboard.health': 'Salud', 'dashboard.settings': 'Configuración', 'dashboard.backups': 'Copias de seguridad',
|
||||
'dashboard.monitoring': 'Monitoreo', 'dashboard.security': 'Seguridad',
|
||||
'service.status.healthy': 'Saludable', 'service.status.degraded': 'Degradado', 'service.status.down': 'Caído',
|
||||
'service.status.unknown': 'Desconocido', 'service.status.pending': 'Pendiente',
|
||||
'action.start': 'Iniciar', 'action.stop': 'Detener', 'action.restart': 'Reiniciar', 'action.delete': 'Eliminar',
|
||||
'action.update': 'Actualizar', 'action.deploy': 'Desplegar', 'action.save': 'Guardar', 'action.cancel': 'Cancelar',
|
||||
|
||||
es: {
|
||||
'dashboard.title': 'Panel de control',
|
||||
'dashboard.services': 'Servicios',
|
||||
'dashboard.containers': 'Contenedores',
|
||||
'dashboard.health': 'Salud',
|
||||
'dashboard.settings': 'Configuración',
|
||||
'dashboard.backups': 'Copias de seguridad',
|
||||
'dashboard.monitoring': 'Monitoreo',
|
||||
'dashboard.security': 'Seguridad',
|
||||
|
||||
'service.status.healthy': 'Saludable',
|
||||
'service.status.degraded': 'Degradado',
|
||||
'service.status.down': 'Caído',
|
||||
'service.status.unknown': 'Desconocido',
|
||||
'service.status.pending': 'Pendiente',
|
||||
|
||||
'action.start': 'Iniciar',
|
||||
'action.stop': 'Detener',
|
||||
'action.restart': 'Reiniciar',
|
||||
'action.delete': 'Eliminar',
|
||||
'action.update': 'Actualizar',
|
||||
'action.deploy': 'Desplegar',
|
||||
'action.save': 'Guardar',
|
||||
'action.cancel': 'Cancelar',
|
||||
'action.confirm': 'Confirmar',
|
||||
'error.not_found': 'Recurso no encontrado', 'error.unauthorized': 'No autorizado', 'error.forbidden': 'Prohibido',
|
||||
'error.rate_limited': 'Demasiadas solicitudes', 'error.internal': 'Error interno del servidor',
|
||||
'error.container_not_found': 'Contenedor no encontrado', 'error.service_not_found': 'Servicio no encontrado',
|
||||
'error.invalid_input': 'Entrada inválida', 'error.docker_unreachable': 'El demonio de Docker no es accesible',
|
||||
|
||||
'error.not_found': 'Recurso no encontrado',
|
||||
'error.unauthorized': 'No autorizado',
|
||||
'error.forbidden': 'Prohibido',
|
||||
'error.rate_limited': 'Demasiadas solicitudes',
|
||||
'error.internal': 'Error interno del servidor',
|
||||
'error.container_not_found': 'Contenedor no encontrado',
|
||||
'error.service_not_found': 'Servicio no encontrado',
|
||||
'error.invalid_input': 'Entrada inválida',
|
||||
'error.docker_unreachable': 'El demonio de Docker no es accesible',
|
||||
'error.disk_full': 'Espacio en disco críticamente bajo',
|
||||
},
|
||||
fa: { // 🇮🇷 فارسی
|
||||
'dashboard.title': 'داشبورد', 'dashboard.services': 'سرویسها', 'dashboard.containers': 'کانتینرها',
|
||||
'dashboard.health': 'سلامت', 'dashboard.settings': 'تنظیمات', 'dashboard.backups': 'پشتیبانگیری',
|
||||
'dashboard.monitoring': 'نظارت', 'dashboard.security': 'امنیت',
|
||||
'service.status.healthy': 'سالم', 'service.status.degraded': 'تنزلیافته', 'service.status.down': 'خراب',
|
||||
'service.status.unknown': 'نامشخص', 'service.status.pending': 'در انتظار',
|
||||
'action.start': 'شروع', 'action.stop': 'توقف', 'action.restart': 'راهاندازی مجدد', 'action.delete': 'حذف',
|
||||
'action.update': 'بهروزرسانی', 'action.deploy': 'استقرار', 'action.save': 'ذخیره', 'action.cancel': 'لغو',
|
||||
'action.confirm': 'تأیید',
|
||||
'error.not_found': 'منبع یافت نشد', 'error.unauthorized': 'غیرمجاز', 'error.forbidden': 'ممنوع',
|
||||
'error.rate_limited': 'درخواستهای بیش از حد', 'error.internal': 'خطای داخلی سرور',
|
||||
'error.container_not_found': 'کانتینر یافت نشد', 'error.service_not_found': 'سرویس یافت نشد',
|
||||
'error.invalid_input': 'ورودی نامعتبر', 'error.docker_unreachable': 'دسترسی به Docker daemon ممکن نیست',
|
||||
'error.disk_full': 'فضای دیسک بهطور بحرانی کم است',
|
||||
},
|
||||
fi: { // 🇫🇮 Suomi
|
||||
'dashboard.title': 'Ohjauspaneeli', 'dashboard.services': 'Palvelut', 'dashboard.containers': 'Kontainerit',
|
||||
'dashboard.health': 'Terveys', 'dashboard.settings': 'Asetukset', 'dashboard.backups': 'Varmuuskopiot',
|
||||
'dashboard.monitoring': 'Valvonta', 'dashboard.security': 'Turvallisuus',
|
||||
'service.status.healthy': 'Terve', 'service.status.degraded': 'Heikentynyt', 'service.status.down': 'Alhaalla',
|
||||
'service.status.unknown': 'Tuntematon', 'service.status.pending': 'Odottaa',
|
||||
'action.start': 'Käynnistä', 'action.stop': 'Pysäytä', 'action.restart': 'Käynnistä uudelleen', 'action.delete': 'Poista',
|
||||
'action.update': 'Päivitä', 'action.deploy': 'Käyttöönotto', 'action.save': 'Tallenna', 'action.cancel': 'Peruuta',
|
||||
'action.confirm': 'Vahvista',
|
||||
'error.not_found': 'Resurssia ei löytynyt', 'error.unauthorized': 'Ei valtuutettu', 'error.forbidden': 'Kielletty',
|
||||
'error.rate_limited': 'Liian monta pyyntöä', 'error.internal': 'Sisäinen palvelinvirhe',
|
||||
'error.container_not_found': 'Kontaineria ei löytynyt', 'error.service_not_found': 'Palvelua ei löytynyt',
|
||||
'error.invalid_input': 'Virheellinen syöte', 'error.docker_unreachable': 'Docker-daemoniin ei saada yhteyttä',
|
||||
'error.disk_full': 'Levytila on kriittisesti vähissä',
|
||||
},
|
||||
fr: { // 🇫🇷 Français
|
||||
'dashboard.title': 'Tableau de bord', 'dashboard.services': 'Services', 'dashboard.containers': 'Conteneurs',
|
||||
'dashboard.health': 'Santé', 'dashboard.settings': 'Paramètres', 'dashboard.backups': 'Sauvegardes',
|
||||
'dashboard.monitoring': 'Surveillance', 'dashboard.security': 'Sécurité',
|
||||
'service.status.healthy': 'Sain', 'service.status.degraded': 'Dégradé', 'service.status.down': 'Hors ligne',
|
||||
'service.status.unknown': 'Inconnu', 'service.status.pending': 'En attente',
|
||||
'action.start': 'Démarrer', 'action.stop': 'Arrêter', 'action.restart': 'Redémarrer', 'action.delete': 'Supprimer',
|
||||
'action.update': 'Mettre à jour', 'action.deploy': 'Déployer', 'action.save': 'Enregistrer', 'action.cancel': 'Annuler',
|
||||
|
||||
fr: {
|
||||
'dashboard.title': 'Tableau de bord',
|
||||
'dashboard.services': 'Services',
|
||||
'dashboard.containers': 'Conteneurs',
|
||||
'dashboard.health': 'Santé',
|
||||
'dashboard.settings': 'Paramètres',
|
||||
'dashboard.backups': 'Sauvegardes',
|
||||
'dashboard.monitoring': 'Surveillance',
|
||||
'dashboard.security': 'Sécurité',
|
||||
|
||||
'service.status.healthy': 'Sain',
|
||||
'service.status.degraded': 'Dégradé',
|
||||
'service.status.down': 'Hors ligne',
|
||||
'service.status.unknown': 'Inconnu',
|
||||
'service.status.pending': 'En attente',
|
||||
|
||||
'action.start': 'Démarrer',
|
||||
'action.stop': 'Arrêter',
|
||||
'action.restart': 'Redémarrer',
|
||||
'action.delete': 'Supprimer',
|
||||
'action.update': 'Mettre à jour',
|
||||
'action.deploy': 'Déployer',
|
||||
'action.save': 'Enregistrer',
|
||||
'action.cancel': 'Annuler',
|
||||
'action.confirm': 'Confirmer',
|
||||
'error.not_found': 'Ressource introuvable', 'error.unauthorized': 'Non autorisé', 'error.forbidden': 'Interdit',
|
||||
'error.rate_limited': 'Trop de requêtes', 'error.internal': 'Erreur interne du serveur',
|
||||
'error.container_not_found': 'Conteneur introuvable', 'error.service_not_found': 'Service introuvable',
|
||||
'error.invalid_input': 'Entrée invalide', 'error.docker_unreachable': 'Le démon Docker est injoignable',
|
||||
|
||||
'error.not_found': 'Ressource introuvable',
|
||||
'error.unauthorized': 'Non autorisé',
|
||||
'error.forbidden': 'Interdit',
|
||||
'error.rate_limited': 'Trop de requêtes',
|
||||
'error.internal': 'Erreur interne du serveur',
|
||||
'error.container_not_found': 'Conteneur introuvable',
|
||||
'error.service_not_found': 'Service introuvable',
|
||||
'error.invalid_input': 'Entrée invalide',
|
||||
'error.docker_unreachable': 'Le démon Docker est injoignable',
|
||||
'error.disk_full': 'Espace disque critique',
|
||||
},
|
||||
hi: { // 🇮🇳 हिन्दी
|
||||
'dashboard.title': 'डैशबोर्ड', 'dashboard.services': 'सेवाएं', 'dashboard.containers': 'कंटेनर',
|
||||
'dashboard.health': 'स्वास्थ्य', 'dashboard.settings': 'सेटिंग्स', 'dashboard.backups': 'बैकअप',
|
||||
'dashboard.monitoring': 'निगरानी', 'dashboard.security': 'सुरक्षा',
|
||||
'service.status.healthy': 'स्वस्थ', 'service.status.degraded': 'क्षतिग्रस्त', 'service.status.down': 'बंद',
|
||||
'service.status.unknown': 'अज्ञात', 'service.status.pending': 'लंबित',
|
||||
'action.start': 'शुरू करें', 'action.stop': 'रोकें', 'action.restart': 'पुनर्प्रारंभ', 'action.delete': 'हटाएं',
|
||||
'action.update': 'अपडेट', 'action.deploy': 'तैनात', 'action.save': 'सहेजें', 'action.cancel': 'रद्द करें',
|
||||
'action.confirm': 'पुष्टि करें',
|
||||
'error.not_found': 'संसाधन नहीं मिला', 'error.unauthorized': 'अनधिकृत', 'error.forbidden': 'निषिद्ध',
|
||||
'error.rate_limited': 'बहुत अधिक अनुरोध', 'error.internal': 'आंतरिक सर्वर त्रुटि',
|
||||
'error.container_not_found': 'कंटेनर नहीं मिला', 'error.service_not_found': 'सेवा नहीं मिली',
|
||||
'error.invalid_input': 'अमान्य इनपुट', 'error.docker_unreachable': 'Docker डेमन तक नहीं पहुंच सकते',
|
||||
'error.disk_full': 'डिस्क स्थान गंभीर रूप से कम है',
|
||||
|
||||
de: {
|
||||
'dashboard.title': 'Dashboard',
|
||||
'dashboard.services': 'Dienste',
|
||||
'dashboard.containers': 'Container',
|
||||
'dashboard.health': 'Zustand',
|
||||
'dashboard.settings': 'Einstellungen',
|
||||
'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Überwachung',
|
||||
'dashboard.security': 'Sicherheit',
|
||||
|
||||
'service.status.healthy': 'Gesund',
|
||||
'service.status.degraded': 'Beeinträchtigt',
|
||||
'service.status.down': 'Ausgefallen',
|
||||
'service.status.unknown': 'Unbekannt',
|
||||
'service.status.pending': 'Ausstehend',
|
||||
|
||||
'action.start': 'Starten',
|
||||
'action.stop': 'Stopp',
|
||||
'action.restart': 'Neustart',
|
||||
'action.delete': 'Löschen',
|
||||
'action.update': 'Aktualisieren',
|
||||
'action.deploy': 'Bereitstellen',
|
||||
'action.save': 'Speichern',
|
||||
'action.cancel': 'Abbrechen',
|
||||
'action.confirm': 'Bestätigen',
|
||||
|
||||
'error.not_found': 'Ressource nicht gefunden',
|
||||
'error.unauthorized': 'Nicht autorisiert',
|
||||
'error.forbidden': 'Verboten',
|
||||
'error.rate_limited': 'Zu viele Anfragen',
|
||||
'error.internal': 'Interner Serverfehler',
|
||||
'error.container_not_found': 'Container nicht gefunden',
|
||||
'error.service_not_found': 'Dienst nicht gefunden',
|
||||
'error.invalid_input': 'Ungültige Eingabe',
|
||||
'error.docker_unreachable': 'Docker-Daemon ist nicht erreichbar',
|
||||
'error.disk_full': 'Speicherplatz kritisch niedrig',
|
||||
},
|
||||
hu: { // 🇭🇺 Magyar
|
||||
'dashboard.title': 'Vezérlőpult', 'dashboard.services': 'Szolgáltatások', 'dashboard.containers': 'Konténerek',
|
||||
'dashboard.health': 'Állapot', 'dashboard.settings': 'Beállítások', 'dashboard.backups': 'Biztonsági mentések',
|
||||
'dashboard.monitoring': 'Figyelés', 'dashboard.security': 'Biztonság',
|
||||
'service.status.healthy': 'Egészséges', 'service.status.degraded': 'Csökkentett', 'service.status.down': 'Leállt',
|
||||
'service.status.unknown': 'Ismeretlen', 'service.status.pending': 'Függőben',
|
||||
'action.start': 'Indítás', 'action.stop': 'Leállítás', 'action.restart': 'Újraindítás', 'action.delete': 'Törlés',
|
||||
'action.update': 'Frissítés', 'action.deploy': 'Telepítés', 'action.save': 'Mentés', 'action.cancel': 'Mégse',
|
||||
'action.confirm': 'Megerősítés',
|
||||
'error.not_found': 'Az erőforrás nem található', 'error.unauthorized': 'Nem engedélyezett', 'error.forbidden': 'Tiltott',
|
||||
'error.rate_limited': 'Túl sok kérés', 'error.internal': 'Belső kiszolgálóhiba',
|
||||
'error.container_not_found': 'A konténer nem található', 'error.service_not_found': 'A szolgáltatás nem található',
|
||||
'error.invalid_input': 'Érvénytelen bemenet', 'error.docker_unreachable': 'A Docker démon nem érhető el',
|
||||
'error.disk_full': 'A lemezterület kritikusan alacsony',
|
||||
},
|
||||
id: { // 🇮🇩 Indonesia
|
||||
'dashboard.title': 'Dasbor', 'dashboard.services': 'Layanan', 'dashboard.containers': 'Kontainer',
|
||||
'dashboard.health': 'Kesehatan', 'dashboard.settings': 'Pengaturan', 'dashboard.backups': 'Pencadangan',
|
||||
'dashboard.monitoring': 'Pemantauan', 'dashboard.security': 'Keamanan',
|
||||
'service.status.healthy': 'Sehat', 'service.status.degraded': 'Terkikis', 'service.status.down': 'Mati',
|
||||
'service.status.unknown': 'Tidak diketahui', 'service.status.pending': 'Tertunda',
|
||||
'action.start': 'Mulai', 'action.stop': 'Berhenti', 'action.restart': 'Mulai ulang', 'action.delete': 'Hapus',
|
||||
'action.update': 'Perbarui', 'action.deploy': 'Sebarkan', 'action.save': 'Simpan', 'action.cancel': 'Batal',
|
||||
'action.confirm': 'Konfirmasi',
|
||||
'error.not_found': 'Sumber daya tidak ditemukan', 'error.unauthorized': 'Tidak berwenang', 'error.forbidden': 'Dilarang',
|
||||
'error.rate_limited': 'Terlalu banyak permintaan', 'error.internal': 'Kesalahan server internal',
|
||||
'error.container_not_found': 'Kontainer tidak ditemukan', 'error.service_not_found': 'Layanan tidak ditemukan',
|
||||
'error.invalid_input': 'Input tidak valid', 'error.docker_unreachable': 'Daemon Docker tidak dapat dijangkau',
|
||||
'error.disk_full': 'Ruang disk sangat rendah',
|
||||
},
|
||||
it: { // 🇮🇹 Italiano
|
||||
'dashboard.title': 'Cruscotto', 'dashboard.services': 'Servizi', 'dashboard.containers': 'Contenitori',
|
||||
'dashboard.health': 'Salute', 'dashboard.settings': 'Impostazioni', 'dashboard.backups': 'Backup',
|
||||
'dashboard.monitoring': 'Monitoraggio', 'dashboard.security': 'Sicurezza',
|
||||
'service.status.healthy': 'Salutare', 'service.status.degraded': 'Danneggiato', 'service.status.down': 'Inattivo',
|
||||
'service.status.unknown': 'Sconosciuto', 'service.status.pending': 'In attesa',
|
||||
'action.start': 'Avvia', 'action.stop': 'Ferma', 'action.restart': 'Riavvia', 'action.delete': 'Elimina',
|
||||
'action.update': 'Aggiorna', 'action.deploy': 'Distribuisci', 'action.save': 'Salva', 'action.cancel': 'Annulla',
|
||||
'action.confirm': 'Conferma',
|
||||
'error.not_found': 'Risorsa non trovata', 'error.unauthorized': 'Non autorizzato', 'error.forbidden': 'Vietato',
|
||||
'error.rate_limited': 'Troppe richieste', 'error.internal': 'Errore interno del server',
|
||||
'error.container_not_found': 'Contenitore non trovato', 'error.service_not_found': 'Servizio non trovato',
|
||||
'error.invalid_input': 'Input non valido', 'error.docker_unreachable': 'Il daemon Docker non è raggiungibile',
|
||||
'error.disk_full': 'Spazio su disco criticamente basso',
|
||||
},
|
||||
ja: { // 🇯🇵 日本語
|
||||
'dashboard.title': 'ダッシュボード', 'dashboard.services': 'サービス', 'dashboard.containers': 'コンテナ',
|
||||
'dashboard.health': 'ヘルス', 'dashboard.settings': '設定', 'dashboard.backups': 'バックアップ',
|
||||
'dashboard.monitoring': '監視', 'dashboard.security': 'セキュリティ',
|
||||
'service.status.healthy': '正常', 'service.status.degraded': '低下', 'service.status.down': '停止',
|
||||
'service.status.unknown': '不明', 'service.status.pending': '保留中',
|
||||
'action.start': '開始', 'action.stop': '停止', 'action.restart': '再起動', 'action.delete': '削除',
|
||||
'action.update': '更新', 'action.deploy': 'デプロイ', 'action.save': '保存', 'action.cancel': 'キャンセル',
|
||||
'action.confirm': '確認',
|
||||
'error.not_found': 'リソースが見つかりません', 'error.unauthorized': '認証されていません', 'error.forbidden': '禁止されています',
|
||||
'error.rate_limited': 'リクエストが多すぎます', 'error.internal': '内部サーバーエラー',
|
||||
'error.container_not_found': 'コンテナが見つかりません', 'error.service_not_found': 'サービスが見つかりません',
|
||||
'error.invalid_input': '無効な入力', 'error.docker_unreachable': 'Dockerデーモンに接続できません',
|
||||
'error.disk_full': 'ディスク容量が致命的に不足しています',
|
||||
},
|
||||
ko: { // 🇰🇷 한국어
|
||||
'dashboard.title': '대시보드', 'dashboard.services': '서비스', 'dashboard.containers': '컨테이너',
|
||||
'dashboard.health': '상태', 'dashboard.settings': '설정', 'dashboard.backups': '백업',
|
||||
'dashboard.monitoring': '모니터링', 'dashboard.security': '보안',
|
||||
'service.status.healthy': '정상', 'service.status.degraded': '성능 저하', 'service.status.down': '중단',
|
||||
'service.status.unknown': '알 수 없음', 'service.status.pending': '대기 중',
|
||||
'action.start': '시작', 'action.stop': '중지', 'action.restart': '재시작', 'action.delete': '삭제',
|
||||
'action.update': '업데이트', 'action.deploy': '배포', 'action.save': '저장', 'action.cancel': '취소',
|
||||
'action.confirm': '확인',
|
||||
'error.not_found': '리소스를 찾을 수 없습니다', 'error.unauthorized': '인증되지 않음', 'error.forbidden': '금지됨',
|
||||
'error.rate_limited': '요청이 너무 많습니다', 'error.internal': '내부 서버 오류',
|
||||
'error.container_not_found': '컨테이너를 찾을 수 없습니다', 'error.service_not_found': '서비스를 찾을 수 없습니다',
|
||||
'error.invalid_input': '잘못된 입력', 'error.docker_unreachable': 'Docker 데몬에 연결할 수 없습니다',
|
||||
'error.disk_full': '디스크 공간이 심각하게 부족합니다',
|
||||
},
|
||||
ms: { // 🇲🇾 Melayu
|
||||
'dashboard.title': 'Papan pemuka', 'dashboard.services': 'Perkhidmatan', 'dashboard.containers': 'Bekas',
|
||||
'dashboard.health': 'Kesihatan', 'dashboard.settings': 'Tetapan', 'dashboard.backups': 'Sandaran',
|
||||
'dashboard.monitoring': 'Pemantauan', 'dashboard.security': 'Keselamatan',
|
||||
'service.status.healthy': 'Sihat', 'service.status.degraded': 'Merosot', 'service.status.down': 'Tergendala',
|
||||
'service.status.unknown': 'Tidak diketahui', 'service.status.pending': 'Belum selesai',
|
||||
'action.start': 'Mula', 'action.stop': 'Berhenti', 'action.restart': 'Mulakan semula', 'action.delete': 'Padam',
|
||||
'action.update': 'Kemas kini', 'action.deploy': 'Lancarkan', 'action.save': 'Simpan', 'action.cancel': 'Batal',
|
||||
'action.confirm': 'Sahkan',
|
||||
'error.not_found': 'Sumber tidak dijumpai', 'error.unauthorized': 'Tidak dibenarkan', 'error.forbidden': 'Dilarang',
|
||||
'error.rate_limited': 'Terlalu banyak permintaan', 'error.internal': 'Ralat pelayan dalaman',
|
||||
'error.container_not_found': 'Bekas tidak dijumpai', 'error.service_not_found': 'Perkhidmatan tidak dijumpai',
|
||||
'error.invalid_input': 'Input tidak sah', 'error.docker_unreachable': 'Docker daemon tidak dapat dijangkau',
|
||||
'error.disk_full': 'Ruang cakera sangat kritikal',
|
||||
},
|
||||
nl: { // 🇳🇱 Nederlands
|
||||
'dashboard.title': 'Dashboard', 'dashboard.services': 'Diensten', 'dashboard.containers': 'Containers',
|
||||
'dashboard.health': 'Status', 'dashboard.settings': 'Instellingen', 'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Bewaking', 'dashboard.security': 'Beveiliging',
|
||||
'service.status.healthy': 'Gezond', 'service.status.degraded': 'Achteruitgegaan', 'service.status.down': 'Offline',
|
||||
'service.status.unknown': 'Onbekend', 'service.status.pending': 'In afwachting',
|
||||
'action.start': 'Starten', 'action.stop': 'Stoppen', 'action.restart': 'Herstarten', 'action.delete': 'Verwijderen',
|
||||
'action.update': 'Bijwerken', 'action.deploy': 'Uitrollen', 'action.save': 'Opslaan', 'action.cancel': 'Annuleren',
|
||||
'action.confirm': 'Bevestigen',
|
||||
'error.not_found': 'Bron niet gevonden', 'error.unauthorized': 'Niet geautoriseerd', 'error.forbidden': 'Verboden',
|
||||
'error.rate_limited': 'Te veel verzoeken', 'error.internal': 'Interne serverfout',
|
||||
'error.container_not_found': 'Container niet gevonden', 'error.service_not_found': 'Dienst niet gevonden',
|
||||
'error.invalid_input': 'Ongeldige invoer', 'error.docker_unreachable': 'Docker-daemon is niet bereikbaar',
|
||||
'error.disk_full': 'Schijfruimte kritiek laag',
|
||||
},
|
||||
no: { // 🇳🇴 Norsk
|
||||
'dashboard.title': 'Kontrollpanel', 'dashboard.services': 'Tjenester', 'dashboard.containers': 'Beholdere',
|
||||
'dashboard.health': 'Helse', 'dashboard.settings': 'Innstillinger', 'dashboard.backups': 'Sikkerhetskopier',
|
||||
'dashboard.monitoring': 'Overvåking', 'dashboard.security': 'Sikkerhet',
|
||||
'service.status.healthy': 'Sunn', 'service.status.degraded': 'Forringet', 'service.status.down': 'Nede',
|
||||
'service.status.unknown': 'Ukjent', 'service.status.pending': 'Venter',
|
||||
'action.start': 'Start', 'action.stop': 'Stopp', 'action.restart': 'Omstart', 'action.delete': 'Slett',
|
||||
'action.update': 'Oppdater', 'action.deploy': 'Rull ut', 'action.save': 'Lagre', 'action.cancel': 'Avbryt',
|
||||
'action.confirm': 'Bekreft',
|
||||
'error.not_found': 'Ressurs ikke funnet', 'error.unauthorized': 'Ikke autorisert', 'error.forbidden': 'Forbudt',
|
||||
'error.rate_limited': 'For mange forespørsler', 'error.internal': 'Intern serverfeil',
|
||||
'error.container_not_found': 'Beholder ikke funnet', 'error.service_not_found': 'Tjeneste ikke funnet',
|
||||
'error.invalid_input': 'Ugyldig inndata', 'error.docker_unreachable': 'Docker-daemon er ikke tilgjengelig',
|
||||
'error.disk_full': 'Diskplassen er kritisk lav',
|
||||
},
|
||||
pl: { // 🇵🇱 Polski
|
||||
'dashboard.title': 'Panel', 'dashboard.services': 'Usługi', 'dashboard.containers': 'Kontenery',
|
||||
'dashboard.health': 'Zdrowie', 'dashboard.settings': 'Ustawienia', 'dashboard.backups': 'Kopie zapasowe',
|
||||
'dashboard.monitoring': 'Monitorowanie', 'dashboard.security': 'Bezpieczeństwo',
|
||||
'service.status.healthy': 'Zdrowy', 'service.status.degraded': 'Naruszony', 'service.status.down': 'Nie działa',
|
||||
'service.status.unknown': 'Nieznany', 'service.status.pending': 'Oczekuje',
|
||||
'action.start': 'Uruchom', 'action.stop': 'Zatrzymaj', 'action.restart': 'Uruchom ponownie', 'action.delete': 'Usuń',
|
||||
'action.update': 'Aktualizuj', 'action.deploy': 'Wdróż', 'action.save': 'Zapisz', 'action.cancel': 'Anuluj',
|
||||
'action.confirm': 'Potwierdź',
|
||||
'error.not_found': 'Nie znaleziono zasobu', 'error.unauthorized': 'Brak autoryzacji', 'error.forbidden': 'Zabronione',
|
||||
'error.rate_limited': 'Zbyt wiele żądań', 'error.internal': 'Wewnętrzny błąd serwera',
|
||||
'error.container_not_found': 'Nie znaleziono kontenera', 'error.service_not_found': 'Nie znaleziono usługi',
|
||||
'error.invalid_input': 'Nieprawidłowe dane wejściowe', 'error.docker_unreachable': 'Daemon Docker jest niedostępny',
|
||||
'error.disk_full': 'Krytycznie mało miejsca na dysku',
|
||||
},
|
||||
pt: { // 🇵🇹 Português
|
||||
'dashboard.title': 'Painel', 'dashboard.services': 'Serviços', 'dashboard.containers': 'Contêineres',
|
||||
'dashboard.health': 'Saúde', 'dashboard.settings': 'Configurações', 'dashboard.backups': 'Backups',
|
||||
'dashboard.monitoring': 'Monitoramento', 'dashboard.security': 'Segurança',
|
||||
'service.status.healthy': 'Saudável', 'service.status.degraded': 'Degradado', 'service.status.down': 'Inativo',
|
||||
'service.status.unknown': 'Desconhecido', 'service.status.pending': 'Pendente',
|
||||
'action.start': 'Iniciar', 'action.stop': 'Parar', 'action.restart': 'Reiniciar', 'action.delete': 'Excluir',
|
||||
'action.update': 'Atualizar', 'action.deploy': 'Implantar', 'action.save': 'Salvar', 'action.cancel': 'Cancelar',
|
||||
'action.confirm': 'Confirmar',
|
||||
'error.not_found': 'Recurso não encontrado', 'error.unauthorized': 'Não autorizado', 'error.forbidden': 'Proibido',
|
||||
'error.rate_limited': 'Muitas solicitações', 'error.internal': 'Erro interno do servidor',
|
||||
'error.container_not_found': 'Contêiner não encontrado', 'error.service_not_found': 'Serviço não encontrado',
|
||||
'error.invalid_input': 'Entrada inválida', 'error.docker_unreachable': 'Daemon do Docker inacessível',
|
||||
'error.disk_full': 'Espaço em disco criticamente baixo',
|
||||
},
|
||||
ro: { // 🇷🇴 Română
|
||||
'dashboard.title': 'Tablou de bord', 'dashboard.services': 'Servicii', 'dashboard.containers': 'Containere',
|
||||
'dashboard.health': 'Stare', 'dashboard.settings': 'Setări', 'dashboard.backups': 'Copii de rezervă',
|
||||
'dashboard.monitoring': 'Monitorizare', 'dashboard.security': 'Securitate',
|
||||
'service.status.healthy': 'Sănătos', 'service.status.degraded': 'Degradat', 'service.status.down': 'Oprit',
|
||||
'service.status.unknown': 'Necunoscut', 'service.status.pending': 'În așteptare',
|
||||
'action.start': 'Pornește', 'action.stop': 'Oprește', 'action.restart': 'Repornește', 'action.delete': 'Șterge',
|
||||
'action.update': 'Actualizează', 'action.deploy': 'Lansează', 'action.save': 'Salvează', 'action.cancel': 'Anulează',
|
||||
'action.confirm': 'Confirmă',
|
||||
'error.not_found': 'Resursă negăsită', 'error.unauthorized': 'Neautorizat', 'error.forbidden': 'Interzis',
|
||||
'error.rate_limited': 'Prea multe cereri', 'error.internal': 'Eroare internă a serverului',
|
||||
'error.container_not_found': 'Container negăsit', 'error.service_not_found': 'Serviciu negăsit',
|
||||
'error.invalid_input': 'Intrare invalidă', 'error.docker_unreachable': 'Daemonul Docker nu poate fi contactat',
|
||||
'error.disk_full': 'Spațiul pe disc este critic de scăzut',
|
||||
},
|
||||
ru: { // 🇷🇺 Русский
|
||||
'dashboard.title': 'Панель управления', 'dashboard.services': 'Сервисы', 'dashboard.containers': 'Контейнеры',
|
||||
'dashboard.health': 'Здоровье', 'dashboard.settings': 'Настройки', 'dashboard.backups': 'Резервные копии',
|
||||
'dashboard.monitoring': 'Мониторинг', 'dashboard.security': 'Безопасность',
|
||||
'service.status.healthy': 'Здоров', 'service.status.degraded': 'Деградирован', 'service.status.down': 'Не работает',
|
||||
'service.status.unknown': 'Неизвестно', 'service.status.pending': 'Ожидание',
|
||||
'action.start': 'Запустить', 'action.stop': 'Остановить', 'action.restart': 'Перезапустить', 'action.delete': 'Удалить',
|
||||
'action.update': 'Обновить', 'action.deploy': 'Развернуть', 'action.save': 'Сохранить', 'action.cancel': 'Отмена',
|
||||
'action.confirm': 'Подтвердить',
|
||||
'error.not_found': 'Ресурс не найден', 'error.unauthorized': 'Не авторизован', 'error.forbidden': 'Запрещено',
|
||||
'error.rate_limited': 'Слишком много запросов', 'error.internal': 'Внутренняя ошибка сервера',
|
||||
'error.container_not_found': 'Контейнер не найден', 'error.service_not_found': 'Сервис не найден',
|
||||
'error.invalid_input': 'Неверный ввод', 'error.docker_unreachable': 'Docker недоступен',
|
||||
'error.disk_full': 'Критически мало места на диске',
|
||||
},
|
||||
sv: { // 🇸🇪 Svenska
|
||||
'dashboard.title': 'Instrumentpanel', 'dashboard.services': 'Tjänster', 'dashboard.containers': 'Behållare',
|
||||
'dashboard.health': 'Hälsa', 'dashboard.settings': 'Inställningar', 'dashboard.backups': 'Säkerhetskopior',
|
||||
'dashboard.monitoring': 'Övervakning', 'dashboard.security': 'Säkerhet',
|
||||
'service.status.healthy': 'Frisk', 'service.status.degraded': 'Nedsatt', 'service.status.down': 'Nere',
|
||||
'service.status.unknown': 'Okänd', 'service.status.pending': 'Väntar',
|
||||
'action.start': 'Starta', 'action.stop': 'Stoppa', 'action.restart': 'Starta om', 'action.delete': 'Ta bort',
|
||||
'action.update': 'Uppdatera', 'action.deploy': 'Distribuera', 'action.save': 'Spara', 'action.cancel': 'Avbryt',
|
||||
'action.confirm': 'Bekräfta',
|
||||
'error.not_found': 'Resurs hittades inte', 'error.unauthorized': 'Obehörig', 'error.forbidden': 'Förbjuden',
|
||||
'error.rate_limited': 'För många förfrågningar', 'error.internal': 'Internt serverfel',
|
||||
'error.container_not_found': 'Behållare hittades inte', 'error.service_not_found': 'Tjänst hittades inte',
|
||||
'error.invalid_input': 'Ogiltig inmatning', 'error.docker_unreachable': 'Docker-daemon kan inte nås',
|
||||
'error.disk_full': 'Diskutrymmet är kritiskt lågt',
|
||||
},
|
||||
th: { // 🇹🇭 ไทย
|
||||
'dashboard.title': 'แดชบอร์ด', 'dashboard.services': 'บริการ', 'dashboard.containers': 'คอนเทนเนอร์',
|
||||
'dashboard.health': 'สถานะ', 'dashboard.settings': 'การตั้งค่า', 'dashboard.backups': 'การสำรองข้อมูล',
|
||||
'dashboard.monitoring': 'การตรวจสอบ', 'dashboard.security': 'ความปลอดภัย',
|
||||
'service.status.healthy': 'ปกติ', 'service.status.degraded': 'เสื่อม', 'service.status.down': 'ล่ม',
|
||||
'service.status.unknown': 'ไม่ทราบ', 'service.status.pending': 'รอดำเนินการ',
|
||||
'action.start': 'เริ่ม', 'action.stop': 'หยุด', 'action.restart': 'รีสตาร์ท', 'action.delete': 'ลบ',
|
||||
'action.update': 'อัปเดต', 'action.deploy': 'ปรับใช้', 'action.save': 'บันทึก', 'action.cancel': 'ยกเลิก',
|
||||
'action.confirm': 'ยืนยัน',
|
||||
'error.not_found': 'ไม่พบทรัพยากร', 'error.unauthorized': 'ไม่ได้รับอนุญาต', 'error.forbidden': 'ห้าม',
|
||||
'error.rate_limited': 'คำขอมากเกินไป', 'error.internal': 'ข้อผิดพลาดภายในเซิร์ฟเวอร์',
|
||||
'error.container_not_found': 'ไม่พบคอนเทนเนอร์', 'error.service_not_found': 'ไม่พบบริการ',
|
||||
'error.invalid_input': 'อินพุตไม่ถูกต้อง', 'error.docker_unreachable': 'ไม่สามารถเข้าถึง Docker daemon ได้',
|
||||
'error.disk_full': 'พื้นที่ดิสก์เหลือน้อยวิกฤต',
|
||||
},
|
||||
tr: { // 🇹🇷 Türkçe
|
||||
'dashboard.title': 'Kontrol Paneli', 'dashboard.services': 'Hizmetler', 'dashboard.containers': 'Konteynerler',
|
||||
'dashboard.health': 'Sağlık', 'dashboard.settings': 'Ayarlar', 'dashboard.backups': 'Yedekler',
|
||||
'dashboard.monitoring': 'İzleme', 'dashboard.security': 'Güvenlik',
|
||||
'service.status.healthy': 'Sağlıklı', 'service.status.degraded': 'Bozulmuş', 'service.status.down': 'Çalışmıyor',
|
||||
'service.status.unknown': 'Bilinmiyor', 'service.status.pending': 'Beklemede',
|
||||
'action.start': 'Başlat', 'action.stop': 'Durdur', 'action.restart': 'Yeniden Başlat', 'action.delete': 'Sil',
|
||||
'action.update': 'Güncelle', 'action.deploy': 'Dağıt', 'action.save': 'Kaydet', 'action.cancel': 'İptal',
|
||||
'action.confirm': 'Onayla',
|
||||
'error.not_found': 'Kaynak bulunamadı', 'error.unauthorized': 'Yetkisiz', 'error.forbidden': 'Yasak',
|
||||
'error.rate_limited': 'Çok fazla istek', 'error.internal': 'Dahili sunucu hatası',
|
||||
'error.container_not_found': 'Konteyner bulunamadı', 'error.service_not_found': 'Hizmet bulunamadı',
|
||||
'error.invalid_input': 'Geçersiz giriş', 'error.docker_unreachable': 'Docker daemonuna ulaşılamıyor',
|
||||
'error.disk_full': 'Disk alanı kritik düzeyde düşük',
|
||||
},
|
||||
uk: { // 🇺🇦 Українська
|
||||
'dashboard.title': 'Панель керування', 'dashboard.services': 'Сервіси', 'dashboard.containers': 'Контейнери',
|
||||
'dashboard.health': "Здоров'я", 'dashboard.settings': 'Налаштування', 'dashboard.backups': 'Резервні копії',
|
||||
'dashboard.monitoring': 'Моніторинг', 'dashboard.security': 'Безпека',
|
||||
'service.status.healthy': 'Здоровий', 'service.status.degraded': 'Деградований', 'service.status.down': 'Не працює',
|
||||
'service.status.unknown': 'Невідомо', 'service.status.pending': 'Очікування',
|
||||
'action.start': 'Запустити', 'action.stop': 'Зупинити', 'action.restart': 'Перезапустити', 'action.delete': 'Видалити',
|
||||
'action.update': 'Оновити', 'action.deploy': 'Розгорнути', 'action.save': 'Зберегти', 'action.cancel': 'Скасувати',
|
||||
'action.confirm': 'Підтвердити',
|
||||
'error.not_found': 'Ресурс не знайдено', 'error.unauthorized': 'Не авторизовано', 'error.forbidden': 'Заборонено',
|
||||
'error.rate_limited': 'Занадто багато запитів', 'error.internal': 'Внутрішня помилка сервера',
|
||||
'error.container_not_found': 'Контейнер не знайдено', 'error.service_not_found': 'Сервіс не знайдено',
|
||||
'error.invalid_input': 'Невірне введення', 'error.docker_unreachable': 'Docker недоступний',
|
||||
'error.disk_full': 'Критично мало місця на диску',
|
||||
},
|
||||
ur: { // 🇵🇰 اردو
|
||||
'dashboard.title': 'ڈیش بورڈ', 'dashboard.services': 'خدمات', 'dashboard.containers': 'کنٹینرز',
|
||||
'dashboard.health': 'صحت', 'dashboard.settings': 'ترتیبات', 'dashboard.backups': 'بیک اپ',
|
||||
'dashboard.monitoring': 'نگرانی', 'dashboard.security': 'تحفظ',
|
||||
'service.status.healthy': 'صحت مند', 'service.status.degraded': 'خراب', 'service.status.down': 'بند',
|
||||
'service.status.unknown': 'نامعلوم', 'service.status.pending': 'زیر التواء',
|
||||
'action.start': 'شروع', 'action.stop': 'روک', 'action.restart': 'دوبارہ شروع', 'action.delete': 'حذف',
|
||||
'action.update': 'اپڈیٹ', 'action.deploy': 'تعینات', 'action.save': 'محفوظ', 'action.cancel': 'منسوخ',
|
||||
'action.confirm': 'تصدیق',
|
||||
'error.not_found': 'وسائل نہیں ملے', 'error.unauthorized': 'غیر مجاز', 'error.forbidden': 'ممنوع',
|
||||
'error.rate_limited': 'بہت زیادہ درخواستیں', 'error.internal': 'اندرونی سرور نقص',
|
||||
'error.container_not_found': 'کنٹینر نہیں ملا', 'error.service_not_found': 'سروس نہیں ملی',
|
||||
'error.invalid_input': 'غلط ان پٹ', 'error.docker_unreachable': 'Docker ڈیمن تک رسائی نہیں',
|
||||
'error.disk_full': 'ڈسک کی جگہ نہایت کم ہے',
|
||||
},
|
||||
vi: { // 🇻🇳 Tiếng Việt
|
||||
'dashboard.title': 'Bảng điều khiển', 'dashboard.services': 'Dịch vụ', 'dashboard.containers': 'Bộ chứa',
|
||||
'dashboard.health': 'Tình trạng', 'dashboard.settings': 'Cài đặt', 'dashboard.backups': 'Sao lưu',
|
||||
'dashboard.monitoring': 'Giám sát', 'dashboard.security': 'Bảo mật',
|
||||
'service.status.healthy': 'Khỏe mạnh', 'service.status.degraded': 'Giảm', 'service.status.down': 'Ngừng',
|
||||
'service.status.unknown': 'Không xác định', 'service.status.pending': 'Đang chờ',
|
||||
'action.start': 'Bắt đầu', 'action.stop': 'Dừng', 'action.restart': 'Khởi động lại', 'action.delete': 'Xóa',
|
||||
'action.update': 'Cập nhật', 'action.deploy': 'Triển khai', 'action.save': 'Lưu', 'action.cancel': 'Hủy',
|
||||
'action.confirm': 'Xác nhận',
|
||||
'error.not_found': 'Không tìm thấy tài nguyên', 'error.unauthorized': 'Không được phép', 'error.forbidden': 'Bị cấm',
|
||||
'error.rate_limited': 'Quá nhiều yêu cầu', 'error.internal': 'Lỗi máy chủ nội bộ',
|
||||
'error.container_not_found': 'Không tìm thấy bộ chứa', 'error.service_not_found': 'Không tìm thấy dịch vụ',
|
||||
'error.invalid_input': 'Đầu vào không hợp lệ', 'error.docker_unreachable': 'Không thể kết nối với Docker daemon',
|
||||
'error.disk_full': 'Không gian đĩa cực kỳ thấp',
|
||||
},
|
||||
zh: { // 🇨🇳 中文
|
||||
'dashboard.title': '仪表盘', 'dashboard.services': '服务', 'dashboard.containers': '容器',
|
||||
'dashboard.health': '健康', 'dashboard.settings': '设置', 'dashboard.backups': '备份',
|
||||
'dashboard.monitoring': '监控', 'dashboard.security': '安全',
|
||||
'service.status.healthy': '健康', 'service.status.degraded': '降级', 'service.status.down': '宕机',
|
||||
'service.status.unknown': '未知', 'service.status.pending': '待处理',
|
||||
'action.start': '启动', 'action.stop': '停止', 'action.restart': '重启', 'action.delete': '删除',
|
||||
'action.update': '更新', 'action.deploy': '部署', 'action.save': '保存', 'action.cancel': '取消',
|
||||
'action.confirm': '确认',
|
||||
'error.not_found': '未找到资源', 'error.unauthorized': '未授权', 'error.forbidden': '禁止访问',
|
||||
'error.rate_limited': '请求过多', 'error.internal': '内部服务器错误',
|
||||
'error.container_not_found': '未找到容器', 'error.service_not_found': '未找到服务',
|
||||
'error.invalid_input': '输入无效', 'error.docker_unreachable': '无法连接 Docker 守护进程',
|
||||
'error.disk_full': '磁盘空间严重不足',
|
||||
|
||||
ar: {
|
||||
'dashboard.title': 'لوحة التحكم',
|
||||
'dashboard.services': 'الخدمات',
|
||||
'dashboard.containers': 'الحاويات',
|
||||
'dashboard.health': 'الصحة',
|
||||
'dashboard.settings': 'الإعدادات',
|
||||
'dashboard.backups': 'النسخ الاحتياطية',
|
||||
'dashboard.monitoring': 'المراقبة',
|
||||
'dashboard.security': 'الأمان',
|
||||
|
||||
'service.status.healthy': 'سليم',
|
||||
'service.status.degraded': 'متدهور',
|
||||
'service.status.down': 'متوقف',
|
||||
'service.status.unknown': 'غير معروف',
|
||||
'service.status.pending': 'قيد الانتظار',
|
||||
|
||||
'action.start': 'تشغيل',
|
||||
'action.stop': 'إيقاف',
|
||||
'action.restart': 'إعادة تشغيل',
|
||||
'action.delete': 'حذف',
|
||||
'action.update': 'تحديث',
|
||||
'action.deploy': 'نشر',
|
||||
'action.save': 'حفظ',
|
||||
'action.cancel': 'إلغاء',
|
||||
'action.confirm': 'تأكيد',
|
||||
|
||||
'error.not_found': 'المورد غير موجود',
|
||||
'error.unauthorized': 'غير مصرح',
|
||||
'error.forbidden': 'محظور',
|
||||
'error.rate_limited': 'طلبات كثيرة جداً',
|
||||
'error.internal': 'خطأ داخلي في الخادم',
|
||||
'error.container_not_found': 'الحاوية غير موجودة',
|
||||
'error.service_not_found': 'الخدمة غير موجودة',
|
||||
'error.invalid_input': 'إدخال غير صالح',
|
||||
'error.docker_unreachable': 'لا يمكن الوصول إلى Docker',
|
||||
'error.disk_full': 'مساحة القرص منخفضة بشكل حرج',
|
||||
},
|
||||
};
|
||||
|
||||
const SUPPORTED_LANGUAGES = Object.keys(TRANSLATIONS);
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
// Language metadata for UI dropdowns
|
||||
const LANGUAGE_META = {
|
||||
en: { name: 'English', flag: '🇺🇸', rtl: false },
|
||||
ar: { name: 'العربية', flag: '🇸🇦', rtl: true },
|
||||
bn: { name: 'বাংলা', flag: '🇧🇩', rtl: false },
|
||||
cs: { name: 'Čeština', flag: '🇨🇿', rtl: false },
|
||||
da: { name: 'Dansk', flag: '🇩🇰', rtl: false },
|
||||
de: { name: 'Deutsch', flag: '🇩🇪', rtl: false },
|
||||
el: { name: 'Ελληνικά', flag: '🇬🇷', rtl: false },
|
||||
es: { name: 'Español', flag: '🇪🇸', rtl: false },
|
||||
fa: { name: 'فارسی', flag: '🇮🇷', rtl: true },
|
||||
fi: { name: 'Suomi', flag: '🇫🇮', rtl: false },
|
||||
fr: { name: 'Français', flag: '🇫🇷', rtl: false },
|
||||
hi: { name: 'हिन्दी', flag: '🇮🇳', rtl: false },
|
||||
hu: { name: 'Magyar', flag: '🇭🇺', rtl: false },
|
||||
id: { name: 'Indonesia', flag: '🇮🇩', rtl: false },
|
||||
it: { name: 'Italiano', flag: '🇮🇹', rtl: false },
|
||||
ja: { name: '日本語', flag: '🇯🇵', rtl: false },
|
||||
ko: { name: '한국어', flag: '🇰🇷', rtl: false },
|
||||
ms: { name: 'Melayu', flag: '🇲🇾', rtl: false },
|
||||
nl: { name: 'Nederlands', flag: '🇳🇱', rtl: false },
|
||||
no: { name: 'Norsk', flag: '🇳🇴', rtl: false },
|
||||
pl: { name: 'Polski', flag: '🇵🇱', rtl: false },
|
||||
pt: { name: 'Português', flag: '🇵🇹', rtl: false },
|
||||
ro: { name: 'Română', flag: '🇷🇴', rtl: false },
|
||||
ru: { name: 'Русский', flag: '🇷🇺', rtl: false },
|
||||
sv: { name: 'Svenska', flag: '🇸🇪', rtl: false },
|
||||
th: { name: 'ไทย', flag: '🇹🇭', rtl: false },
|
||||
tr: { name: 'Türkçe', flag: '🇹🇷', rtl: false },
|
||||
uk: { name: 'Українська', flag: '🇺🇦', rtl: false },
|
||||
ur: { name: 'اردو', flag: '🇵🇰', rtl: true },
|
||||
vi: { name: 'Tiếng Việt', flag: '🇻🇳', rtl: false },
|
||||
zh: { name: '中文', flag: '🇨🇳', rtl: false },
|
||||
};
|
||||
|
||||
function t(key, lang) {
|
||||
lang = lang || DEFAULT_LANGUAGE;
|
||||
var dict = TRANSLATIONS[lang] || TRANSLATIONS[DEFAULT_LANGUAGE];
|
||||
/**
|
||||
* Translate a key to the specified language.
|
||||
* Falls back to English, then to the key itself if not found.
|
||||
*/
|
||||
function t(key, lang = DEFAULT_LANGUAGE) {
|
||||
const dict = TRANSLATIONS[lang] || TRANSLATIONS[DEFAULT_LANGUAGE];
|
||||
return dict[key] || TRANSLATIONS[DEFAULT_LANGUAGE][key] || key;
|
||||
}
|
||||
function getSupportedLanguages() { return SUPPORTED_LANGUAGES; }
|
||||
function getLanguageMeta(lang) { return LANGUAGE_META[lang] || LANGUAGE_META[DEFAULT_LANGUAGE]; }
|
||||
function getAllLanguages() { return LANGUAGE_META; }
|
||||
function isRTL(lang) { return lang === 'ar' || lang === 'fa' || lang === 'ur'; }
|
||||
function isSupported(lang) { return SUPPORTED_LANGUAGES.indexOf(lang) >= 0; }
|
||||
|
||||
/**
|
||||
* Get the list of supported languages
|
||||
*/
|
||||
function getSupportedLanguages() {
|
||||
return SUPPORTED_LANGUAGES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language is supported
|
||||
*/
|
||||
function isSupported(lang) {
|
||||
return SUPPORTED_LANGUAGES.includes(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language from Accept-Language header
|
||||
*/
|
||||
function detectLanguage(acceptLanguage) {
|
||||
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
||||
var parts = acceptLanguage.split(',');
|
||||
var entries = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var seg = parts[i].trim();
|
||||
if (!seg) continue;
|
||||
var bits = seg.split(';');
|
||||
var code = bits[0].split('-')[0].trim().toLowerCase();
|
||||
if (!code) continue;
|
||||
var q = 1.0;
|
||||
for (var j = 1; j < bits.length; j++) {
|
||||
var kv = bits[j].trim().split('=');
|
||||
if (kv.length === 2 && kv[0].trim().toLowerCase() === 'q') {
|
||||
var qStr = kv[1].trim();
|
||||
// RFC 7231 §5.3.1: qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3"0" ] )
|
||||
// Match the strict grammar; values that do not conform are treated as
|
||||
// "no q-value specified" and fall back to q=1.0, the HTTP default.
|
||||
var qMatch = qStr.match(/^(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/);
|
||||
if (qMatch) {
|
||||
q = parseFloat(qMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.push({ code: code, q: q, order: i });
|
||||
const langs = acceptLanguage.split(',').map(l => {
|
||||
const [code, q] = l.trim().split(';q=');
|
||||
return { code: code.split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 };
|
||||
}).sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const { code } of langs) {
|
||||
if (isSupported(code)) return code;
|
||||
}
|
||||
entries.sort(function (a, b) {
|
||||
if (b.q !== a.q) return b.q - a.q;
|
||||
return a.order - b.order;
|
||||
});
|
||||
for (var k = 0; k < entries.length; k++) {
|
||||
if (entries[k].q === 0) continue;
|
||||
if (isSupported(entries[k].code)) return entries[k].code;
|
||||
}
|
||||
// Intentional design policy: when every supported entry was explicitly
|
||||
// refused with q=0 (or no supported language was offered), fall back to the
|
||||
// server default (DEFAULT_LANGUAGE) rather than honoring the refusal.
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
t, getSupportedLanguages, getLanguageMeta, getAllLanguages,
|
||||
isRTL, isSupported, detectLanguage, DEFAULT_LANGUAGE,
|
||||
TRANSLATIONS, LANGUAGE_META,
|
||||
t,
|
||||
getSupportedLanguages,
|
||||
isSupported,
|
||||
detectLanguage,
|
||||
DEFAULT_LANGUAGE,
|
||||
TRANSLATIONS,
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Recursive data nesting guard.
|
||||
*
|
||||
* In past versions, a buggy update/restore path created data/data/data/...
|
||||
* directories — each containing a full recursive copy of the parent.
|
||||
* This module runs at startup, detects and removes nested duplicates.
|
||||
*
|
||||
* Add to app.js: require('./utilities/nesting-guard')();
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function nestingGuard() {
|
||||
try {
|
||||
const paths = require('../config/paths');
|
||||
const dataDir = paths.dataDir;
|
||||
const dataDataPath = path.join(dataDir, 'data');
|
||||
|
||||
// If data/data exists, it's a recursive duplicate — remove it
|
||||
if (fs.existsSync(dataDataPath)) {
|
||||
const stat = fs.statSync(dataDataPath);
|
||||
if (stat.isDirectory()) {
|
||||
// Verify it's truly a duplicate (contains config.json like the parent)
|
||||
const markerFile = path.join(dataDataPath, 'config.json');
|
||||
const parentMarker = path.join(dataDir, 'config.json');
|
||||
if (fs.existsSync(markerFile) && fs.existsSync(parentMarker)) {
|
||||
console.log('[nesting-guard] Removing recursive data nesting: ' + dataDataPath);
|
||||
fs.rmSync(dataDataPath, { recursive: true, force: true });
|
||||
console.log('[nesting-guard] Recursive nesting removed');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal — don't crash startup over cleanup
|
||||
console.warn('[nesting-guard] Skipped: ' + e.message);
|
||||
}
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# install-installer.sh — Installs vintage-radio-install.sh into /usr/local/bin.
|
||||
#
|
||||
# Run this once on a host to make `bash /usr/local/bin/vintage-radio-install.sh`
|
||||
# available as a system command. Idempotent.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC="${SELF_DIR}/install.sh"
|
||||
DEST="/usr/local/bin/vintage-radio-install.sh"
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo "FATAL: $SRC not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0755 "$SRC" "$DEST"
|
||||
echo "Installed: $SRC -> $DEST"
|
||||
echo "Run it with: bash $DEST"
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# vintage-radio-install.sh — Materializes the Vintage Stereo bundled web assets.
|
||||
#
|
||||
# The Vintage Stereo radio template serves its UI through an nginx:alpine
|
||||
# container that mounts /opt/vintage-radio/web as /usr/share/nginx/html. This
|
||||
# script copies the assets (index.html, radio.css, radio.js, stations.json)
|
||||
# from the DashCaddy source tree into that mount target.
|
||||
#
|
||||
# Usage:
|
||||
# bash /usr/local/bin/vintage-radio-install.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# DASHCADDY_ROOT — Path to the DashCaddy install root (defaults to /opt/dashcaddy).
|
||||
# TARGET_DIR — Mount target directory (defaults to /opt/vintage-radio/web).
|
||||
#
|
||||
# Idempotent: safe to re-run; overwrites the target files each time.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DASHCADDY_ROOT="${DASHCADDY_ROOT:-/opt/dashcaddy}"
|
||||
TARGET_DIR="${TARGET_DIR:-/opt/vintage-radio/web}"
|
||||
SOURCE_DIR="${DASHCADDY_ROOT}/dashcaddy-api/static-sites/vintage-radio/web"
|
||||
|
||||
if [[ ! -d "$SOURCE_DIR" ]]; then
|
||||
echo "FATAL: source assets not found at $SOURCE_DIR" >&2
|
||||
echo " Install DashCaddy, or set DASHCADDY_ROOT to its location." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SOURCE_DIR/index.html" || ! -f "$SOURCE_DIR/radio.css" \
|
||||
|| ! -f "$SOURCE_DIR/radio.js" || ! -f "$SOURCE_DIR/stations.json" ]]; then
|
||||
echo "FATAL: incomplete assets in $SOURCE_DIR" >&2
|
||||
ls -la "$SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
install -m 0644 "$SOURCE_DIR/index.html" "$TARGET_DIR/index.html"
|
||||
install -m 0644 "$SOURCE_DIR/radio.css" "$TARGET_DIR/radio.css"
|
||||
install -m 0644 "$SOURCE_DIR/radio.js" "$TARGET_DIR/radio.js"
|
||||
install -m 0644 "$SOURCE_DIR/stations.json" "$TARGET_DIR/stations.json"
|
||||
|
||||
chmod 0755 "$TARGET_DIR"
|
||||
|
||||
echo "Vintage Stereo assets installed:"
|
||||
echo " Source: $SOURCE_DIR"
|
||||
echo " Target: $TARGET_DIR"
|
||||
ls -la "$TARGET_DIR"
|
||||
@@ -1,143 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Vintage Stereo</title>
|
||||
<link rel="stylesheet" href="radio.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="room">
|
||||
<div class="console" id="console">
|
||||
|
||||
<!-- ====== LEFT: wood grain end cap, controls column ====== -->
|
||||
<aside class="endcap endcap-left">
|
||||
<button class="knob knob-power" id="powerBtn" type="button" aria-pressed="false" aria-label="Power">
|
||||
<div class="knob-face">
|
||||
<div class="knob-indicator"></div>
|
||||
</div>
|
||||
<span class="knob-label">PWR</span>
|
||||
</button>
|
||||
|
||||
<button class="knob knob-mode" id="modeBtn" type="button" aria-pressed="false" aria-label="Cycle genre mode">
|
||||
<div class="knob-face">
|
||||
<div class="knob-indicator"></div>
|
||||
</div>
|
||||
<span class="knob-label">MODE</span>
|
||||
<span class="knob-mode-name" id="modeName">ALL</span>
|
||||
</button>
|
||||
|
||||
<button class="knob knob-mute" id="muteBtn" type="button" aria-pressed="false" aria-label="Mute">
|
||||
<div class="knob-face">
|
||||
<div class="knob-indicator"></div>
|
||||
</div>
|
||||
<span class="knob-label">MUTE</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- ====== CENTER: smoked-glass face revealing controls underneath ====== -->
|
||||
<section class="glass-face" aria-label="Stereo faceplate">
|
||||
<div class="glass-overlay"></div>
|
||||
|
||||
<!-- Backlit dial display visible through the glass -->
|
||||
<div class="dial-window">
|
||||
<div class="dial-frequency" id="dialFrequency">--.-</div>
|
||||
<div class="dial-station" id="dialStation">VINTAGE STEREO</div>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal slide-rule tuning rail -->
|
||||
<div class="dial-rail-wrap">
|
||||
<button
|
||||
class="dial-rail"
|
||||
id="dialRail"
|
||||
type="button"
|
||||
aria-label="Tuning rail. Drag horizontally or use left and right arrow keys."
|
||||
>
|
||||
<div class="dial-ticks" id="dialTicks"></div>
|
||||
<div class="dial-stop" id="dialStop1"></div>
|
||||
<div class="dial-stop" id="dialStop2"></div>
|
||||
<div class="dial-stop" id="dialStop3"></div>
|
||||
<div class="dial-cursor" id="dialCursor">
|
||||
<div class="cursor-line"></div>
|
||||
<div class="cursor-flag"></div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="dial-scale">
|
||||
<span>88</span><span>92</span><span>96</span><span>100</span><span>104</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Twin VU meters -->
|
||||
<div class="vu-row">
|
||||
<div class="vu-meter" aria-hidden="true">
|
||||
<div class="vu-falloff" id="vuLeftFalloff"></div>
|
||||
<div class="vu-needle" id="vuLeft"></div>
|
||||
<div class="vu-label">L</div>
|
||||
<div class="vu-bg-marks">
|
||||
<span></span><span></span><span></span><span></span><span></span><span class="red"></span><span class="red"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vu-meter" aria-hidden="true">
|
||||
<div class="vu-falloff" id="vuRightFalloff"></div>
|
||||
<div class="vu-needle" id="vuRight"></div>
|
||||
<div class="vu-label">R</div>
|
||||
<div class="vu-bg-marks">
|
||||
<span></span><span></span><span></span><span></span><span></span><span class="red"></span><span class="red"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Power LED + status row -->
|
||||
<div class="status-row">
|
||||
<span class="led" id="powerLed"></span>
|
||||
<span class="status-text" id="statusText">Standby</span>
|
||||
<span class="led led-signal" id="signalLed"></span>
|
||||
<span class="status-text" id="signalText">Signal</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ====== RIGHT: knob array + volume slider ====== -->
|
||||
<aside class="endcap endcap-right">
|
||||
<div class="volume-block">
|
||||
<span class="block-label">VOLUME</span>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" class="volume-slider" aria-label="Volume" />
|
||||
<div class="volume-readout" id="volumeReadout">70</div>
|
||||
</div>
|
||||
|
||||
<div class="preset-block">
|
||||
<span class="block-label">PRESETS</span>
|
||||
<div class="preset-buttons">
|
||||
<button class="preset" id="prevBtn" type="button" aria-label="Previous station">◀◀</button>
|
||||
<button class="preset" id="nextBtn" type="button" aria-label="Next station">▶▶</button>
|
||||
</div>
|
||||
<div class="preset-label" id="presetLabel">— / —</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ====== Speaker grille (bottom) ====== -->
|
||||
<div class="grille" aria-hidden="true">
|
||||
<div class="grille-fabric"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ====== Side panel: station index ====== -->
|
||||
<aside class="panel" id="panel">
|
||||
<header class="panel-head">
|
||||
<h1>STATION INDEX</h1>
|
||||
<p class="panel-sub">tune the dial or click a station</p>
|
||||
</header>
|
||||
<ul class="station-list" id="stationList" role="listbox" aria-label="Available stations"></ul>
|
||||
<footer class="panel-foot">
|
||||
<span id="nowPlaying">Power: standby</span>
|
||||
<span class="sep">|</span>
|
||||
<span id="streamInfo"></span>
|
||||
</footer>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<audio id="player" preload="none" crossorigin="anonymous"></audio>
|
||||
|
||||
<script src="radio.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,682 +0,0 @@
|
||||
/* Vintage Stereo — glass-front console stereo styling */
|
||||
|
||||
:root {
|
||||
--wood-light: #c89466;
|
||||
--wood-mid: #8a5326;
|
||||
--wood-dark: #3e2110;
|
||||
--wood-cap: #2a160a;
|
||||
--brushed: #d4cfc2;
|
||||
--brushed-dk: #807a6e;
|
||||
--face: #b8b2a3;
|
||||
--face-dk: #615d54;
|
||||
--led-off: #341a10;
|
||||
--led-on: #ff5733;
|
||||
--dial-glow: #ffa84a;
|
||||
--vu-glow: #f1c40f;
|
||||
--knob-cap: #1d1814;
|
||||
--ink: #14110a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, #1f140a 0%, #0a0604 80%);
|
||||
color: var(--ink);
|
||||
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(640px, 1fr) 340px;
|
||||
gap: 24px;
|
||||
padding: 28px;
|
||||
align-items: stretch;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.room {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.room > .console { justify-self: center; }
|
||||
.room > .panel { min-height: 60vh; }
|
||||
}
|
||||
|
||||
/* Very narrow phones: zoom the console down to fit the viewport.
|
||||
Note: `zoom` is supported in Chrome/Edge/Safari and Firefox 126+. Older Firefox
|
||||
falls back to the unzoomed layout (with mild horizontal overflow). */
|
||||
@media (max-width: 760px) {
|
||||
html, body { overflow: auto; }
|
||||
.room { padding: 12px; }
|
||||
.room > .console { zoom: 0.92; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.room > .console { zoom: 0.78; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.room > .console { zoom: 0.62; }
|
||||
}
|
||||
|
||||
/* ====== Console ====== */
|
||||
|
||||
.console {
|
||||
position: relative;
|
||||
background:
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(255,255,255,0.05) 0 2px,
|
||||
transparent 2px 5px),
|
||||
linear-gradient(180deg, var(--wood-light) 0%, var(--wood-mid) 50%, var(--wood-dark) 100%);
|
||||
border-radius: 24px;
|
||||
padding: 0;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,0.25),
|
||||
inset 0 -30px 80px rgba(0,0,0,0.55),
|
||||
0 30px 80px rgba(0,0,0,0.6),
|
||||
0 0 0 8px var(--wood-cap);
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr 200px;
|
||||
grid-template-rows: 360px 1fr;
|
||||
grid-template-areas:
|
||||
"left face right"
|
||||
"grille grille grille";
|
||||
min-height: 720px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 20px;
|
||||
border: 2px solid rgba(0,0,0,0.35);
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
/* ====== End caps (left & right wooden panels with knobs) ====== */
|
||||
|
||||
.endcap {
|
||||
background: linear-gradient(180deg, var(--wood-mid) 0%, var(--wood-dark) 100%);
|
||||
padding: 22px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: inset 8px 0 18px rgba(0,0,0,0.45);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.endcap-left { grid-area: left; border-right: 2px solid rgba(0,0,0,0.4); }
|
||||
.endcap-right { grid-area: right; border-left: 2px solid rgba(0,0,0,0.4); box-shadow: inset -8px 0 18px rgba(0,0,0,0.45); }
|
||||
|
||||
/* ====== Knobs ====== */
|
||||
|
||||
.knob {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #f4ead0;
|
||||
font-size: 9px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.knob-face {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, #f0e8d4 0%, #8a7e5e 60%, #1c1610 100%);
|
||||
border: 2px solid #0a0805;
|
||||
box-shadow:
|
||||
0 3px 6px rgba(0,0,0,0.5),
|
||||
inset 0 -1px 2px rgba(255,255,255,0.18),
|
||||
inset 0 2px 4px rgba(255,255,255,0.15);
|
||||
position: relative;
|
||||
transition: transform 0.05s;
|
||||
}
|
||||
|
||||
.knob:active .knob-face { transform: translateY(1px); }
|
||||
|
||||
.knob-indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: var(--led-on);
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
box-shadow: 0 0 4px var(--led-on);
|
||||
}
|
||||
|
||||
.knob-power[aria-pressed="true"] .knob-indicator {
|
||||
box-shadow: 0 0 10px var(--led-on), 0 0 16px rgba(255,87,51,0.4);
|
||||
}
|
||||
|
||||
.knob-label {
|
||||
font-weight: bold;
|
||||
color: var(--brushed);
|
||||
text-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.knob-mode-name {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--dial-glow);
|
||||
background: #1a0d05;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #0a0805;
|
||||
margin-top: -2px;
|
||||
text-shadow: 0 0 3px var(--dial-glow);
|
||||
}
|
||||
|
||||
/* ====== Glass face ====== */
|
||||
|
||||
.glass-face {
|
||||
grid-area: face;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(180deg, #c4beae 0%, #a39c8b 50%, #7a7363 100%);
|
||||
padding: 28px 32px 22px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The smoked-glass overlay that sits ON TOP of all face contents */
|
||||
.glass-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(20, 14, 6, 0.18) 0%, rgba(20, 14, 6, 0.35) 100%),
|
||||
repeating-linear-gradient(135deg,
|
||||
rgba(255,255,255,0.04) 0 1px,
|
||||
transparent 1px 4px);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255,255,255,0.45),
|
||||
inset 0 0 30px rgba(0,0,0,0.35);
|
||||
border-left: 2px solid rgba(0,0,0,0.4);
|
||||
border-right: 2px solid rgba(0,0,0,0.4);
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.glass-face > *:not(.glass-overlay) { position: relative; z-index: 2; }
|
||||
|
||||
/* Faint streaks like a polished-glass reflection */
|
||||
.glass-face::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(120deg,
|
||||
transparent 30%,
|
||||
rgba(255,255,255,0.18) 38%,
|
||||
transparent 46%,
|
||||
rgba(255,255,255,0.08) 60%,
|
||||
transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* ====== Dial window: backlit section behind glass ====== */
|
||||
|
||||
.dial-window {
|
||||
background:
|
||||
linear-gradient(180deg, #1a0d05 0%, #2b1608 100%);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #0a0805;
|
||||
text-align: center;
|
||||
box-shadow:
|
||||
inset 0 2px 6px rgba(0,0,0,0.7),
|
||||
0 0 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.dial-frequency {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 56px;
|
||||
font-weight: bold;
|
||||
color: var(--dial-glow);
|
||||
letter-spacing: 4px;
|
||||
line-height: 1;
|
||||
text-shadow:
|
||||
0 0 8px var(--dial-glow),
|
||||
0 0 18px rgba(255,168,74,0.4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.console[data-power="off"] .dial-frequency { color: #4a2b14; text-shadow: none; }
|
||||
|
||||
.dial-station {
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 5px;
|
||||
color: #f6e6c8;
|
||||
text-shadow: 0 0 6px rgba(255,176,102,0.4);
|
||||
}
|
||||
|
||||
.console[data-power="off"] .dial-station { color: #4a2b14; text-shadow: none; }
|
||||
|
||||
/* ====== Tuning rail ====== */
|
||||
|
||||
.dial-rail-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dial-rail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
background:
|
||||
linear-gradient(180deg, #161109 0%, #2a1c0b 100%);
|
||||
border-radius: 6px;
|
||||
border: 2px solid #0a0805;
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dial-ticks {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(255,168,74,0.25) 0 1px,
|
||||
transparent 1px 2px,
|
||||
rgba(255,168,74,0.5) 8px 9px,
|
||||
rgba(255,168,74,0.15) 9px 14px);
|
||||
}
|
||||
|
||||
.dial-stop {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
background: var(--dial-glow);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 4px var(--dial-glow);
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dial-cursor {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
pointer-events: none;
|
||||
transition: left 0.18s ease-out;
|
||||
}
|
||||
|
||||
.cursor-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -1px;
|
||||
width: 2px;
|
||||
background: var(--led-on);
|
||||
box-shadow: 0 0 6px var(--led-on), 0 0 12px rgba(255,87,51,0.5);
|
||||
}
|
||||
|
||||
.cursor-flag {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -7px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-bottom: 8px solid var(--led-on);
|
||||
filter: drop-shadow(0 0 4px var(--led-on));
|
||||
}
|
||||
|
||||
.dial-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--face-dk);
|
||||
letter-spacing: 1px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ====== Twin VU meters ====== */
|
||||
|
||||
.vu-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.vu-meter {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
background:
|
||||
linear-gradient(180deg, #f7f0d8 0%, #d8cfb5 100%);
|
||||
border-radius: 6px;
|
||||
border: 2px solid #0a0805;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.vu-needle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1.5px;
|
||||
height: 100%;
|
||||
background: #c0392b;
|
||||
transform-origin: bottom center;
|
||||
transition: transform 0.12s ease-out;
|
||||
}
|
||||
|
||||
.vu-falloff {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 49%, rgba(0,0,0,0.15) 50%, transparent 51%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vu-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.vu-bg-marks {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.vu-bg-marks span {
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background: rgba(60, 40, 25, 0.6);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vu-bg-marks span.red { background: #c0392b; }
|
||||
|
||||
/* ====== Status row under glass ====== */
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--face-dk);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--led-off);
|
||||
box-shadow: inset 0 1px 1px rgba(255,255,255,0.2);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.console[data-power="on"] .led { background: var(--led-on); box-shadow: 0 0 8px var(--led-on), inset 0 1px 1px rgba(255,255,255,0.3); }
|
||||
|
||||
.led-signal { background: #2a1608; }
|
||||
|
||||
.console[data-power="on"][data-streaming="true"] .led-signal {
|
||||
background: #2ecc71;
|
||||
box-shadow: 0 0 6px #2ecc71, inset 0 1px 1px rgba(255,255,255,0.3);
|
||||
animation: signal-pulse 1.6s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes signal-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.status-text { font-weight: bold; text-transform: uppercase; }
|
||||
|
||||
/* ====== Right end cap: volume + presets ====== */
|
||||
|
||||
.volume-block, .preset-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--brushed);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
width: 28px;
|
||||
height: 100px;
|
||||
accent-color: var(--led-on);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-readout {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--dial-glow);
|
||||
text-shadow: 0 0 6px var(--dial-glow);
|
||||
background: #1a0d05;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #0a0805;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preset-buttons { display: flex; gap: 6px; }
|
||||
|
||||
.preset {
|
||||
background: var(--brushed);
|
||||
border: 2px solid var(--brushed-dk);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: inset 0 -2px 3px rgba(0,0,0,0.25), 0 2px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.preset:active { transform: translateY(1px); box-shadow: inset 0 2px 3px rgba(0,0,0,0.25), 0 0 0 transparent; }
|
||||
|
||||
.preset:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.preset-label {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--dial-glow);
|
||||
text-shadow: 0 0 4px var(--dial-glow);
|
||||
background: #1a0d05;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #0a0805;
|
||||
}
|
||||
|
||||
/* ====== Speaker grille (spans full bottom) ====== */
|
||||
|
||||
.grille {
|
||||
grid-area: grille;
|
||||
background:
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(0,0,0,0.85) 0 2px,
|
||||
rgba(255,255,255,0.04) 2px 6px);
|
||||
border-top: 4px solid rgba(0,0,0,0.5);
|
||||
box-shadow: inset 0 4px 12px rgba(0,0,0,0.6);
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.grille-fabric {
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
background:
|
||||
repeating-linear-gradient(90deg,
|
||||
rgba(0,0,0,0.4) 0 3px,
|
||||
rgba(120, 80, 40, 0.2) 3px 6px),
|
||||
radial-gradient(ellipse at center, rgba(0,0,0,0.4) 0%, transparent 70%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ====== Side panel ====== */
|
||||
|
||||
.panel {
|
||||
background: linear-gradient(180deg, #1a120a 0%, #0d0805 100%);
|
||||
color: #d4c9a8;
|
||||
border-radius: 22px;
|
||||
padding: 22px;
|
||||
border: 2px solid var(--wood-dark);
|
||||
box-shadow:
|
||||
inset 0 0 30px rgba(0,0,0,0.6),
|
||||
0 12px 30px rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--dial-glow);
|
||||
text-shadow: 0 0 8px var(--dial-glow);
|
||||
}
|
||||
|
||||
.panel-sub {
|
||||
margin: 4px 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1.5px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.station-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.station-list li {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.05s;
|
||||
}
|
||||
|
||||
.station-list li:hover { background: rgba(255,176,102,0.08); border-color: rgba(255,176,102,0.3); }
|
||||
|
||||
.station-list li[aria-selected="true"] {
|
||||
background: rgba(255,176,102,0.15);
|
||||
border-color: var(--dial-glow);
|
||||
}
|
||||
|
||||
.station-list li:active { transform: translateX(2px); }
|
||||
|
||||
.station-freq {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dial-glow);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.station-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
|
||||
.station-name {
|
||||
font-size: 14px;
|
||||
color: #f4ead0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.station-genre {
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255,176,102,0.2);
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.panel-foot .sep { opacity: 0.4; }
|
||||
|
||||
#streamInfo.live::before {
|
||||
content: "\25CF";
|
||||
color: var(--led-on);
|
||||
margin-right: 4px;
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
30% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
.station-list::-webkit-scrollbar { width: 6px; }
|
||||
.station-list::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
|
||||
.station-list::-webkit-scrollbar-thumb { background: var(--wood-mid); border-radius: 3px; }
|
||||
@@ -1,474 +0,0 @@
|
||||
// Vintage Stereo — tuner logic for the glass-front console stereo
|
||||
// Loads stations from /stations.json, manages playback through an <audio>
|
||||
// element, and drives the analog dial / VU meters / status panel.
|
||||
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const els = {
|
||||
console: document.getElementById('console'),
|
||||
dialRail: document.getElementById('dialRail'),
|
||||
dialCursor: document.getElementById('dialCursor'),
|
||||
dialFrequency: document.getElementById('dialFrequency'),
|
||||
dialStation: document.getElementById('dialStation'),
|
||||
vuLeft: document.getElementById('vuLeft'),
|
||||
vuRight: document.getElementById('vuRight'),
|
||||
powerBtn: document.getElementById('powerBtn'),
|
||||
modeBtn: document.getElementById('modeBtn'),
|
||||
modeName: document.getElementById('modeName'),
|
||||
muteBtn: document.getElementById('muteBtn'),
|
||||
prevBtn: document.getElementById('prevBtn'),
|
||||
nextBtn: document.getElementById('nextBtn'),
|
||||
volumeSlider: document.getElementById('volumeSlider'),
|
||||
volumeReadout: document.getElementById('volumeReadout'),
|
||||
presetLabel: document.getElementById('presetLabel'),
|
||||
stationList: document.getElementById('stationList'),
|
||||
player: document.getElementById('player'),
|
||||
statusText: document.getElementById('statusText'),
|
||||
signalText: document.getElementById('signalText'),
|
||||
nowPlaying: document.getElementById('nowPlaying'),
|
||||
streamInfo: document.getElementById('streamInfo'),
|
||||
};
|
||||
|
||||
const FILTER_MODES = [
|
||||
{ name: 'ALL', match: () => true },
|
||||
{ name: 'AMBIENT', match: (s) => /ambient|space|lounge|chill|downtempo|nasa/i.test(s.genre + ' ' + s.name) },
|
||||
{ name: 'ROCK', match: (s) => /rock|indie|pop|folk|synth|wave|electronic|secret|beat/i.test(s.genre + ' ' + s.name) },
|
||||
{ name: 'MIXED', match: (s) => /paradise|eclectic|mix|indie|kexp|public/i.test(s.genre + ' ' + s.name) },
|
||||
];
|
||||
|
||||
const STATE = {
|
||||
stations: [],
|
||||
visibleStations: [],
|
||||
currentIndex: -1,
|
||||
power: false,
|
||||
muted: false,
|
||||
volume: 0.7,
|
||||
filterMode: 0,
|
||||
};
|
||||
|
||||
// ====== Loading ======
|
||||
|
||||
async function loadStations() {
|
||||
try {
|
||||
const res = await fetch('stations.json', { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
STATE.stations = (data.stations || [])
|
||||
.slice()
|
||||
.sort((a, b) => a.freq - b.freq);
|
||||
applyFilter();
|
||||
if (STATE.visibleStations.length > 0) {
|
||||
tuneTo(0);
|
||||
} else {
|
||||
setStatus('No stations in this mode');
|
||||
els.dialStation.textContent = 'NO STATIONS';
|
||||
}
|
||||
updatePrevNextDisabled();
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message);
|
||||
els.dialStation.textContent = 'OFFLINE';
|
||||
els.dialFrequency.textContent = '---.-';
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const mode = FILTER_MODES[STATE.filterMode];
|
||||
const filtered = STATE.stations.filter(mode.match);
|
||||
STATE.visibleStations = filtered.length > 0 ? filtered : STATE.stations.slice();
|
||||
renderStationList();
|
||||
updatePresetLabel();
|
||||
const cur = STATE.stations[STATE.currentIndex];
|
||||
if (!cur || !STATE.visibleStations.includes(cur)) {
|
||||
// Current station was filtered out — pick the visible station closest by frequency
|
||||
// to the current station's frequency (not always the first visible station).
|
||||
if (STATE.visibleStations.length > 0) {
|
||||
let bestRealIdx = STATE.stations.indexOf(STATE.visibleStations[0]);
|
||||
let bestDiff = Math.abs(STATE.stations[bestRealIdx].freq - (cur ? cur.freq : 0));
|
||||
for (let i = 1; i < STATE.visibleStations.length; i++) {
|
||||
const real = STATE.stations.indexOf(STATE.visibleStations[i]);
|
||||
const d = Math.abs(STATE.stations[real].freq - (cur ? cur.freq : 0));
|
||||
if (d < bestDiff) { bestDiff = d; bestRealIdx = real; }
|
||||
}
|
||||
if (bestRealIdx !== STATE.currentIndex) {
|
||||
STATE.currentIndex = bestRealIdx;
|
||||
updateDialFromStation();
|
||||
updateStationListSelection();
|
||||
// Station changed — restart playback to match the displayed selection.
|
||||
if (STATE.power) startStream();
|
||||
}
|
||||
}
|
||||
} else if (STATE.power) {
|
||||
// Current station is still in the filtered set, but MODE has changed — restart
|
||||
// playback so any per-mode audio-affecting state (volume, readyState) catches up.
|
||||
startStream();
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Rendering ======
|
||||
|
||||
function renderStationList() {
|
||||
els.stationList.innerHTML = '';
|
||||
STATE.visibleStations.forEach((s) => {
|
||||
const realIdx = STATE.stations.indexOf(s);
|
||||
const li = document.createElement('li');
|
||||
li.setAttribute('role', 'option');
|
||||
li.dataset.index = String(realIdx);
|
||||
const freq = document.createElement('span');
|
||||
freq.className = 'station-freq';
|
||||
freq.textContent = s.freq.toFixed(1);
|
||||
const info = document.createElement('span');
|
||||
info.className = 'station-info';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'station-name';
|
||||
name.textContent = s.name;
|
||||
const genre = document.createElement('span');
|
||||
genre.className = 'station-genre';
|
||||
genre.textContent = s.genre;
|
||||
info.appendChild(name);
|
||||
info.appendChild(genre);
|
||||
li.appendChild(freq);
|
||||
li.appendChild(info);
|
||||
li.addEventListener('click', () => {
|
||||
tuneTo(realIdx);
|
||||
// tuneTo() already restarts the stream if powered — no need to also play().
|
||||
});
|
||||
els.stationList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function updateStationListSelection() {
|
||||
els.stationList.querySelectorAll('li').forEach((li) => {
|
||||
const idx = Number(li.dataset.index);
|
||||
li.setAttribute('aria-selected', idx === STATE.currentIndex ? 'true' : 'false');
|
||||
});
|
||||
const sel = els.stationList.querySelector('li[aria-selected="true"]');
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function updatePresetLabel() {
|
||||
const total = STATE.visibleStations.length;
|
||||
const cur = total > 0 ? (visibleIndexOfCurrent() + 1) : 0;
|
||||
els.presetLabel.textContent = cur.toString().padStart(2, '0') + ' / ' + total.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
function visibleIndexOfCurrent() {
|
||||
if (STATE.currentIndex < 0) return -1;
|
||||
const cur = STATE.stations[STATE.currentIndex];
|
||||
return STATE.visibleStations.indexOf(cur);
|
||||
}
|
||||
|
||||
// ====== Tuning ======
|
||||
|
||||
function updateDialFromStation() {
|
||||
if (STATE.currentIndex < 0 || STATE.stations.length === 0) return;
|
||||
const s = STATE.stations[STATE.currentIndex];
|
||||
const t = (s.freq - 88) / (105.4 - 88);
|
||||
const pct = Math.max(0, Math.min(1, t)) * 100;
|
||||
els.dialCursor.style.left = pct + '%';
|
||||
els.dialFrequency.textContent = s.freq.toFixed(1);
|
||||
els.dialStation.textContent = s.name.toUpperCase();
|
||||
els.nowPlaying.textContent = s.name + ' \u00b7 ' + s.genre;
|
||||
updatePresetLabel();
|
||||
}
|
||||
|
||||
function tuneTo(index) {
|
||||
if (index < 0 || index >= STATE.stations.length) return;
|
||||
if (!STATE.visibleStations.includes(STATE.stations[index])) {
|
||||
// Defensive: caller asked for a filtered-out station — pick the closest visible
|
||||
// station by frequency instead of resetting the active filter.
|
||||
const targetFreq = STATE.stations[index].freq;
|
||||
let bestRealIdx = STATE.stations.indexOf(STATE.visibleStations[0]);
|
||||
let bestDiff = Math.abs(STATE.stations[bestRealIdx].freq - targetFreq);
|
||||
for (let i = 1; i < STATE.visibleStations.length; i++) {
|
||||
const real = STATE.stations.indexOf(STATE.visibleStations[i]);
|
||||
const d = Math.abs(STATE.stations[real].freq - targetFreq);
|
||||
if (d < bestDiff) { bestDiff = d; bestRealIdx = real; }
|
||||
}
|
||||
index = bestRealIdx;
|
||||
}
|
||||
STATE.currentIndex = index;
|
||||
updateDialFromStation();
|
||||
updateStationListSelection();
|
||||
updatePrevNextDisabled();
|
||||
if (STATE.power) startStream();
|
||||
}
|
||||
|
||||
function tuneToVisibleIndex(vi) {
|
||||
if (vi < 0 || vi >= STATE.visibleStations.length) return;
|
||||
const target = STATE.visibleStations[vi];
|
||||
const realIdx = STATE.stations.indexOf(target);
|
||||
if (realIdx !== STATE.currentIndex) tuneTo(realIdx);
|
||||
}
|
||||
|
||||
function tuneToFreq(freq) {
|
||||
if (STATE.visibleStations.length === 0) return;
|
||||
let bestRealIdx = STATE.stations.indexOf(STATE.visibleStations[0]);
|
||||
let bestDiff = Math.abs(STATE.stations[bestRealIdx].freq - freq);
|
||||
for (let i = 1; i < STATE.visibleStations.length; i++) {
|
||||
const real = STATE.stations.indexOf(STATE.visibleStations[i]);
|
||||
const d = Math.abs(STATE.stations[real].freq - freq);
|
||||
if (d < bestDiff) { bestDiff = d; bestRealIdx = real; }
|
||||
}
|
||||
if (bestRealIdx !== STATE.currentIndex) tuneTo(bestRealIdx);
|
||||
}
|
||||
|
||||
// ====== Playback ======
|
||||
|
||||
function startStream() {
|
||||
const s = STATE.stations[STATE.currentIndex];
|
||||
if (!s) return;
|
||||
const targetUrl = s.url;
|
||||
if (els.player.src !== targetUrl) {
|
||||
els.player.src = targetUrl;
|
||||
els.player.load();
|
||||
} else {
|
||||
// Same URL, but caller wants a fresh start — rewind and reload to flush
|
||||
// any buffered state from a previous mode/stream.
|
||||
try { els.player.currentTime = 0; } catch (_) { /* some streams reject */ }
|
||||
els.player.load();
|
||||
}
|
||||
const playPromise = els.player.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch((err) => {
|
||||
setStatus('Audio error: ' + err.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
try { els.player.pause(); } catch (_) { /* ignore */ }
|
||||
els.player.removeAttribute('src');
|
||||
els.player.load();
|
||||
els.console.dataset.streaming = 'false';
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!STATE.power) return;
|
||||
startStream();
|
||||
}
|
||||
|
||||
// ====== Power ======
|
||||
|
||||
function setPower(on) {
|
||||
STATE.power = on;
|
||||
els.console.dataset.power = on ? 'on' : 'off';
|
||||
els.powerBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
setStatus(on ? 'Power on' : 'Standby');
|
||||
setSignal(on ? 'Tuning' : 'Idle', on);
|
||||
if (on) startStream();
|
||||
else stopStream();
|
||||
updatePrevNextDisabled();
|
||||
}
|
||||
|
||||
function updatePrevNextDisabled() {
|
||||
const visibleIdx = visibleIndexOfCurrent();
|
||||
const total = STATE.visibleStations.length;
|
||||
const canPrev = visibleIdx > 0;
|
||||
const canNext = visibleIdx >= 0 && visibleIdx < total - 1;
|
||||
els.prevBtn.disabled = !canPrev;
|
||||
els.nextBtn.disabled = !canNext;
|
||||
}
|
||||
|
||||
// ====== Volume / Mute ======
|
||||
|
||||
function applyVolume() {
|
||||
const v = STATE.muted ? 0 : STATE.volume;
|
||||
els.player.volume = v;
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
STATE.muted = !STATE.muted;
|
||||
els.muteBtn.setAttribute('aria-pressed', STATE.muted ? 'true' : 'false');
|
||||
applyVolume();
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
els.statusText.textContent = msg;
|
||||
if (!STATE.power) els.nowPlaying.textContent = 'Power: ' + msg.toLowerCase();
|
||||
}
|
||||
|
||||
function setSignal(msg, on) {
|
||||
els.signalText.textContent = msg;
|
||||
}
|
||||
|
||||
// ====== Mode (genre filter) ======
|
||||
|
||||
function cycleMode() {
|
||||
STATE.filterMode = (STATE.filterMode + 1) % FILTER_MODES.length;
|
||||
applyFilter();
|
||||
const name = FILTER_MODES[STATE.filterMode].name;
|
||||
els.modeName.textContent = name;
|
||||
els.modeBtn.setAttribute('aria-label', 'Cycle genre mode. Currently ' + name + '.');
|
||||
setStatus('Mode: ' + name);
|
||||
updatePrevNextDisabled();
|
||||
}
|
||||
|
||||
// ====== VU meter animation ======
|
||||
|
||||
let vuAnimHandle = null;
|
||||
let leftEnergy = 0;
|
||||
let rightEnergy = 0;
|
||||
|
||||
function animateVu() {
|
||||
if (!STATE.power) {
|
||||
els.vuLeft.style.transform = 'rotate(0deg)';
|
||||
els.vuRight.style.transform = 'rotate(0deg)';
|
||||
vuAnimHandle = requestAnimationFrame(animateVu);
|
||||
return;
|
||||
}
|
||||
|
||||
if (els.player.paused || els.player.readyState < 2) {
|
||||
leftEnergy = leftEnergy * 0.85 + (Math.random() * 4 - 2) * 0.15;
|
||||
rightEnergy = rightEnergy * 0.85 + (Math.random() * 4 - 2) * 0.15;
|
||||
} else {
|
||||
const base = -8;
|
||||
const peak = Math.random() < 0.06 ? 32 : Math.random() * 16;
|
||||
const l = base + peak + (Math.random() - 0.5) * 5;
|
||||
const r = base + peak + (Math.random() - 0.5) * 5;
|
||||
leftEnergy = leftEnergy * 0.6 + l * 0.4;
|
||||
rightEnergy = rightEnergy * 0.6 + r * 0.4;
|
||||
}
|
||||
|
||||
els.vuLeft.style.transform = 'rotate(' + leftEnergy.toFixed(1) + 'deg)';
|
||||
els.vuRight.style.transform = 'rotate(' + rightEnergy.toFixed(1) + 'deg)';
|
||||
vuAnimHandle = requestAnimationFrame(animateVu);
|
||||
}
|
||||
|
||||
// ====== Dial interaction ======
|
||||
|
||||
let dragging = false;
|
||||
|
||||
function railXToFreq(clientX) {
|
||||
const rect = els.dialRail.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
|
||||
const t = x / rect.width;
|
||||
return 88 + t * (105.4 - 88);
|
||||
}
|
||||
|
||||
function onDialPointerDown(e) {
|
||||
dragging = true;
|
||||
els.dialRail.setPointerCapture(e.pointerId);
|
||||
tuneToFreq(railXToFreq(e.clientX));
|
||||
}
|
||||
|
||||
function onDialPointerMove(e) {
|
||||
if (!dragging) return;
|
||||
tuneToFreq(railXToFreq(e.clientX));
|
||||
}
|
||||
|
||||
function onDialPointerUp(e) {
|
||||
dragging = false;
|
||||
try { els.dialRail.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
function onDialWheel(e) {
|
||||
e.preventDefault();
|
||||
if (STATE.visibleStations.length === 0) return;
|
||||
const dir = e.deltaY > 0 ? 1 : -1;
|
||||
const vi = visibleIndexOfCurrent();
|
||||
tuneToVisibleIndex(Math.max(0, Math.min(STATE.visibleStations.length - 1, vi + dir)));
|
||||
}
|
||||
|
||||
function onDialKey(e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const vi = visibleIndexOfCurrent();
|
||||
if (vi > 0) tuneToVisibleIndex(vi - 1);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const vi = visibleIndexOfCurrent();
|
||||
if (vi >= 0 && vi < STATE.visibleStations.length - 1) tuneToVisibleIndex(vi + 1);
|
||||
} else if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Streaming indicator ======
|
||||
|
||||
function updateStreamIndicator() {
|
||||
const streaming = STATE.power
|
||||
&& !els.player.paused
|
||||
&& els.player.readyState >= 2
|
||||
&& els.player.error === null;
|
||||
els.console.dataset.streaming = streaming ? 'true' : 'false';
|
||||
if (STATE.power) {
|
||||
if (streaming) {
|
||||
const s = STATE.stations[STATE.currentIndex];
|
||||
els.streamInfo.textContent = s ? s.name : '';
|
||||
els.streamInfo.classList.add('live');
|
||||
setSignal('Streaming', true);
|
||||
} else if (els.player.error) {
|
||||
setSignal('No signal', false);
|
||||
els.streamInfo.classList.remove('live');
|
||||
els.streamInfo.textContent = '';
|
||||
} else {
|
||||
setSignal('Tuning', true);
|
||||
els.streamInfo.classList.remove('live');
|
||||
}
|
||||
} else {
|
||||
els.streamInfo.classList.remove('live');
|
||||
els.streamInfo.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Wire up ======
|
||||
|
||||
function init() {
|
||||
els.console.dataset.power = 'off';
|
||||
els.console.dataset.streaming = 'false';
|
||||
els.player.volume = STATE.volume;
|
||||
els.modeName.textContent = FILTER_MODES[STATE.filterMode].name;
|
||||
els.modeBtn.setAttribute('aria-label', 'Cycle genre mode. Currently ' + FILTER_MODES[STATE.filterMode].name + '.');
|
||||
|
||||
els.player.addEventListener('playing', updateStreamIndicator);
|
||||
els.player.addEventListener('pause', updateStreamIndicator);
|
||||
els.player.addEventListener('waiting', updateStreamIndicator);
|
||||
els.player.addEventListener('stalled', updateStreamIndicator);
|
||||
els.player.addEventListener('error', () => {
|
||||
setSignal('No signal', false);
|
||||
els.streamInfo.classList.remove('live');
|
||||
els.streamInfo.textContent = 'stream error';
|
||||
});
|
||||
|
||||
els.powerBtn.addEventListener('click', () => setPower(!STATE.power));
|
||||
els.muteBtn.addEventListener('click', toggleMute);
|
||||
els.modeBtn.addEventListener('click', cycleMode);
|
||||
|
||||
els.prevBtn.addEventListener('click', () => {
|
||||
const vi = visibleIndexOfCurrent();
|
||||
if (vi > 0) tuneToVisibleIndex(vi - 1);
|
||||
});
|
||||
els.nextBtn.addEventListener('click', () => {
|
||||
const vi = visibleIndexOfCurrent();
|
||||
if (vi >= 0 && vi < STATE.visibleStations.length - 1) tuneToVisibleIndex(vi + 1);
|
||||
});
|
||||
|
||||
els.volumeSlider.addEventListener('input', (e) => {
|
||||
const pct = Number(e.target.value);
|
||||
STATE.volume = pct / 100;
|
||||
els.volumeReadout.textContent = pct;
|
||||
if (STATE.muted && pct > 0) toggleMute();
|
||||
applyVolume();
|
||||
});
|
||||
|
||||
els.dialRail.addEventListener('pointerdown', onDialPointerDown);
|
||||
els.dialRail.addEventListener('pointermove', onDialPointerMove);
|
||||
els.dialRail.addEventListener('pointerup', onDialPointerUp);
|
||||
els.dialRail.addEventListener('pointercancel', onDialPointerUp);
|
||||
els.dialRail.addEventListener('wheel', onDialWheel, { passive: false });
|
||||
els.dialRail.addEventListener('keydown', onDialKey);
|
||||
|
||||
setInterval(updateStreamIndicator, 1500);
|
||||
|
||||
vuAnimHandle = requestAnimationFrame(animateVu);
|
||||
loadStations();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"stations": [
|
||||
{ "freq": 88.5, "name": "Groove Salad", "genre": "Ambient / Downtempo", "url": "https://ice1.somafm.com/groovesalad-128-mp3", "color": "#7cb342" },
|
||||
{ "freq": 89.2, "name": "Drone Zone", "genre": "Ambient / Space", "url": "https://ice1.somafm.com/dronezone-128-mp3", "color": "#26c6da" },
|
||||
{ "freq": 90.1, "name": "Deep Space One", "genre": "Ambient / Electronic", "url": "https://ice1.somafm.com/deepspaceone-128-mp3", "color": "#5c6bc0" },
|
||||
{ "freq": 91.3, "name": "Lush", "genre": "Vocal Electronica", "url": "https://ice1.somafm.com/lush-128-mp3", "color": "#ab47bc" },
|
||||
{ "freq": 92.7, "name": "Underground 80s", "genre": "Early New Wave", "url": "https://ice1.somafm.com/u80s-128-mp3", "color": "#ec407a" },
|
||||
{ "freq": 93.5, "name": "Indie Pop Rocks!", "genre": "Indie Pop", "url": "https://ice1.somafm.com/indiepop-128-mp3", "color": "#ff7043" },
|
||||
{ "freq": 94.9, "name": "Mission Control", "genre": "NASA Audio / Talk", "url": "https://ice2.somafm.com/missioncontrol-128-mp3", "color": "#8d6e63" },
|
||||
{ "freq": 95.6, "name": "cliqhop idm", "genre": "IDM / Experimental", "url": "https://ice2.somafm.com/cliqhop-128-mp3", "color": "#42a5f5" },
|
||||
{ "freq": 96.4, "name": "Folk Forward", "genre": "Contemporary Folk", "url": "https://ice2.somafm.com/folkfwd-128-mp3", "color": "#d4a373" },
|
||||
{ "freq": 97.2, "name": "Left Coast 70s", "genre": "Classic Rock", "url": "https://ice2.somafm.com/seventies-128-mp3", "color": "#ffb300" },
|
||||
{ "freq": 98.0, "name": "SF 10\u201333", "genre": "Ambient / Chill", "url": "https://ice1.somafm.com/sf1033-128-mp3", "color": "#26a69a" },
|
||||
{ "freq": 98.8, "name": "Space Station Soma", "genre": "Ambient / Electronic", "url": "https://ice2.somafm.com/spacestation-128-mp3", "color": "#7e57c2" },
|
||||
{ "freq": 99.6, "name": "Suburbs of Goa", "genre": "Desi-Inspired Electronica", "url": "https://ice2.somafm.com/suburbsofgoa-128-mp3", "color": "#fdd835" },
|
||||
{ "freq": 100.4, "name": "Secret Agent", "genre": "Lounge / Spy Jazz", "url": "https://ice1.somafm.com/secretagent-128-mp3", "color": "#5d4037" },
|
||||
{ "freq": 101.8, "name": "Beat Blender", "genre": "Deep House / Downtempo", "url": "https://ice2.somafm.com/beatblender-128-mp3", "color": "#ef5350" },
|
||||
{ "freq": 102.5, "name": "Synphaera Radio", "genre": "Vaporwave / Future Funk", "url": "https://ice2.somafm.com/synphaera-128-mp3", "color": "#ff80ab" },
|
||||
{ "freq": 103.6, "name": "Radio Paradise", "genre": "Eclectic Main Mix", "url": "https://stream.radioparadise.com/aac-128", "color": "#43a047" },
|
||||
{ "freq": 105.4, "name": "KEXP Seattle", "genre": "Public Radio / Indie", "url": "https://kexp-mp3-128.streamguys1.com/kexp128.mp3", "color": "#1e88e5" }
|
||||
]
|
||||
}
|
||||
@@ -5,4 +5,3 @@ dist/
|
||||
Thumbs.db
|
||||
LOGO_INTEGRATION.md
|
||||
README-TESTER.txt
|
||||
build-output
|
||||
@@ -17,7 +17,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Constants -------------------------------------------------------------
|
||||
readonly DASHCADDY_VERSION="1.15.0"
|
||||
readonly DASHCADDY_VERSION="1.14.6"
|
||||
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 INSTALL_DIR="/etc/dashcaddy"
|
||||
@@ -35,7 +35,6 @@ API_PORT=3001
|
||||
LOCAL_PORT=8080
|
||||
BACKUP_DIR=""
|
||||
BACKUP_LIMIT=""
|
||||
DISK_SIZE=""
|
||||
|
||||
# ---- Runtime state ---------------------------------------------------------
|
||||
DOMAIN_MODE="" # public | custom-tld | local
|
||||
@@ -387,92 +386,6 @@ EOF
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -796,17 +709,9 @@ services:
|
||||
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.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:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
mem_limit: 1024m
|
||||
memswap_limit: 2048m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -930,6 +835,22 @@ start_caddy() {
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -995,9 +916,7 @@ do_uninstall() {
|
||||
fi
|
||||
|
||||
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}/"
|
||||
else
|
||||
rm -rf "$INSTALL_DIR"
|
||||
@@ -1031,7 +950,6 @@ parse_args() {
|
||||
--keep-config) KEEP_CONFIG=true; shift ;;
|
||||
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
||||
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
||||
--disk-size) DISK_SIZE="${2:-}"; shift; shift ;;
|
||||
--yes|-y) AUTO_YES=true; shift ;;
|
||||
--help|-h) print_help; exit 0 ;;
|
||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||
@@ -1068,8 +986,6 @@ print_help() {
|
||||
--skip-caddy Already have Caddy
|
||||
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
||||
--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
|
||||
--keep-config Keep configs during uninstall
|
||||
--yes Skip confirmations
|
||||
@@ -1114,7 +1030,6 @@ print_success() {
|
||||
[[ -n "$lan_url" ]] && echo -e " ${BOLD}LAN access:${NC} ${lan_url}"
|
||||
echo ""
|
||||
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}"
|
||||
|
||||
if [[ "$DOMAIN_MODE" == "public" ]]; then
|
||||
@@ -1174,8 +1089,6 @@ main() {
|
||||
|
||||
# ---- Step 4: Deploy files ----
|
||||
step "Deploying DashCaddy"
|
||||
create_disk_sandbox
|
||||
|
||||
create_directories
|
||||
fetch_source
|
||||
create_seed_configs
|
||||
@@ -1194,6 +1107,7 @@ main() {
|
||||
# ---- Step 7: Start Caddy ----
|
||||
step "Starting web server"
|
||||
start_caddy
|
||||
install_api_symlink
|
||||
|
||||
print_success "$(elapsed "$start_time")"
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"name": "dashcaddy-installer",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.9.1",
|
||||
@@ -49,6 +46,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1734,6 +1732,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -1925,7 +1924,6 @@
|
||||
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -1945,7 +1943,6 @@
|
||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -1968,7 +1965,6 @@
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -1984,8 +1980,7 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
@@ -1993,7 +1988,6 @@
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -2002,6 +1996,7 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assert-plus": {
|
||||
@@ -2220,7 +2215,6 @@
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -2296,6 +2290,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2726,7 +2721,6 @@
|
||||
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -2833,7 +2827,6 @@
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -2847,7 +2840,6 @@
|
||||
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -2897,6 +2889,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3091,6 +3084,7 @@
|
||||
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
@@ -3275,7 +3269,6 @@
|
||||
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -3289,7 +3282,6 @@
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -3305,7 +3297,6 @@
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -3319,7 +3310,6 @@
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3423,82 +3413,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-updater": {
|
||||
"version": "6.8.9",
|
||||
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
|
||||
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"builder-util-runtime": "9.7.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lazy-val": "^1.0.5",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"semver": "~7.7.3",
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/builder-util-runtime": {
|
||||
"version": "9.7.0",
|
||||
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
|
||||
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emittery": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
|
||||
@@ -3885,8 +3799,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
@@ -4186,6 +4099,7 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
@@ -4519,8 +4433,7 @@
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.7",
|
||||
@@ -5278,6 +5191,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -5375,6 +5289,7 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
@@ -5383,7 +5298,6 @@
|
||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -5397,7 +5311,6 @@
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -5413,8 +5326,7 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
@@ -5422,7 +5334,6 @@
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -5469,21 +5380,13 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.escaperegexp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
@@ -5491,14 +5394,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
@@ -5506,16 +5401,14 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
@@ -5757,6 +5650,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -6111,8 +6005,7 @@
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
@@ -6234,7 +6127,6 @@
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -6250,7 +6142,6 @@
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -6387,8 +6278,7 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -6411,6 +6301,7 @@
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
@@ -6634,7 +6525,6 @@
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -6808,7 +6698,6 @@
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -6908,12 +6797,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
@@ -7067,8 +6950,7 @@
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
@@ -7269,7 +7151,6 @@
|
||||
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -7285,7 +7166,6 @@
|
||||
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
|
||||
@@ -47,24 +47,12 @@
|
||||
{
|
||||
"from": "../status",
|
||||
"to": "status",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!node_modules/**",
|
||||
"!.git/**",
|
||||
"!**/*.test.js",
|
||||
"!**/*.spec.js"
|
||||
]
|
||||
"filter": ["**/*", "!node_modules/**", "!.git/**", "!**/*.test.js", "!**/*.spec.js"]
|
||||
},
|
||||
{
|
||||
"from": "../dashcaddy-api",
|
||||
"to": "dashcaddy-api",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!node_modules/**",
|
||||
"!.git/**",
|
||||
"!**/*.test.js",
|
||||
"!**/*.spec.js"
|
||||
]
|
||||
"filter": ["**/*", "!node_modules/**", "!.git/**", "!**/*.test.js", "!**/*.spec.js"]
|
||||
}
|
||||
],
|
||||
"icon": "assets/favicon.ico",
|
||||
@@ -77,9 +65,7 @@
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"zip"
|
||||
],
|
||||
"target": "dmg",
|
||||
"icon": "assets/dashcaddy-logo.png"
|
||||
},
|
||||
"linux": {
|
||||
@@ -96,13 +82,6 @@
|
||||
"installerIcon": "assets/icon.ico",
|
||||
"uninstallerIcon": "assets/icon.ico",
|
||||
"installerHeaderIcon": "assets/icon.ico"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://get.dashcaddy.net/release/"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9"
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"name": "dashcaddy-installer",
|
||||
"version": "1.0.0",
|
||||
"description": "Cross-platform installer for DashCaddy platform",
|
||||
"main": "src/main/index.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"build": "electron-builder",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:linux": "electron-builder --linux"
|
||||
},
|
||||
"keywords": [
|
||||
"dashcaddy",
|
||||
"installer",
|
||||
"docker",
|
||||
"caddy"
|
||||
],
|
||||
"author": {
|
||||
"name": "DashCaddy Team",
|
||||
"email": "dashcaddy@sami.cloud"
|
||||
},
|
||||
"homepage": "https://github.com/dashcaddy/dashcaddy",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.9.1",
|
||||
"fast-check": "^3.15.0",
|
||||
"jest": "^29.7.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.dashcaddy.installer",
|
||||
"productName": "DashCaddy Installer",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "build-output"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"assets/**/*",
|
||||
"templates/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../status",
|
||||
"to": "status",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!node_modules/**",
|
||||
"!.git/**",
|
||||
"!**/*.test.js",
|
||||
"!**/*.spec.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../dashcaddy-api",
|
||||
"to": "dashcaddy-api",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!node_modules/**",
|
||||
"!.git/**",
|
||||
"!**/*.test.js",
|
||||
"!**/*.spec.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/favicon.ico",
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
],
|
||||
"icon": "assets/favicon.ico",
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"zip"
|
||||
],
|
||||
"icon": "assets/dashcaddy-logo.png"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"icon": "assets/dashcaddy-logo.png",
|
||||
"category": "Utility"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"installerIcon": "assets/icon.ico",
|
||||
"uninstallerIcon": "assets/icon.ico",
|
||||
"installerHeaderIcon": "assets/icon.ico"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9"
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,6 @@ const { DEFAULT_PORTS } = require('../shared/constants');
|
||||
*
|
||||
* Generates production-grade configs that match the patterns used by the
|
||||
* 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 {
|
||||
/**
|
||||
@@ -283,43 +279,19 @@ class CaddyfileGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Generate docker-compose.yml for running the API server
|
||||
* @param {string} installPath - Installation directory
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.apiPort - API server port
|
||||
* @param {string} options.lanIP - Host LAN IP address
|
||||
* @param {string} options.tailscaleIP - Host Tailscale IP address
|
||||
* @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 = {}) {
|
||||
const apiPort = options.apiPort || DEFAULT_PORTS.API;
|
||||
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
|
||||
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
|
||||
let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw
|
||||
- ${p(installPath)}/services.json:/app/services.json:rw
|
||||
@@ -336,19 +308,12 @@ class CaddyfileGenerator {
|
||||
volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`;
|
||||
}
|
||||
|
||||
// Environment variables — disk safety baked in
|
||||
// Environment variables
|
||||
let envVars = ` - CADDYFILE_PATH=/caddyfile
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:${adminPort}
|
||||
- ASSETS_PATH=/app/assets
|
||||
- CREDENTIALS_FILE=/app/credentials.json
|
||||
- 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}`;
|
||||
- NODE_ENV=production`;
|
||||
|
||||
if (options.domainMode === 'custom-tld') {
|
||||
envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`;
|
||||
@@ -374,9 +339,6 @@ ${envVars}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
# Memory limit prevents OOM during startup when all managers init
|
||||
mem_limit: ${memoryLimitMB}m
|
||||
memswap_limit: ${(memoryLimitMB * 2)}m
|
||||
`;
|
||||
|
||||
return dockerCompose;
|
||||
|
||||
@@ -10,106 +10,6 @@ process.on('uncaughtException', (error) => {
|
||||
});
|
||||
|
||||
let mainWindow;
|
||||
const { registerVMHandlers } = require('./vm-ipc');
|
||||
|
||||
// --- Auto-updater (electron-updater) ---
|
||||
// Checks get.dashcaddy.net for new installer versions. Failures are silent
|
||||
// so offline / air-gapped hosts are unaffected.
|
||||
const { autoUpdater, Notification } = require('electron-updater');
|
||||
const UPDATE_FEED_URL = 'https://get.dashcaddy.net/release/';
|
||||
|
||||
function configureAutoUpdater() {
|
||||
autoUpdater.autoDownload = true; // download silently in background
|
||||
autoUpdater.autoInstallOnAppQuit = true; // install on next quit
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL });
|
||||
|
||||
// Graceful error handling — never crash on update failures
|
||||
autoUpdater.on('error', (error) => {
|
||||
console.error('[Updater] Error:', error == null ? 'unknown' : error.message || String(error));
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log('[Updater] Update available:', info && info.version);
|
||||
try {
|
||||
// Show a desktop notification if supported; renderer is notified via IPC too
|
||||
if (Notification && Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: 'A new version of DashCaddy is available',
|
||||
body: `Version ${info && info.version ? info.version : 'new'} is downloading and will install when you quit.`,
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
} catch (e) {
|
||||
// notifications may be unsupported (headless) — ignore
|
||||
}
|
||||
// Forward to the wizard so it can show an in-app banner
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-available', {
|
||||
version: info && info.version ? info.version : null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
console.log('[Updater] Up to date.');
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
// keep verbose; useful for debugging but not surfaced to UI unless desired
|
||||
if (progress && progress.percent) {
|
||||
console.log(`[Updater] Downloading update: ${Math.round(progress.percent)}%`);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log('[Updater] Update downloaded; will install on quit.', info && info.version);
|
||||
try {
|
||||
if (Notification && Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: 'DashCaddy update ready',
|
||||
body: 'It will be installed automatically when you quit the installer.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-downloaded', {
|
||||
version: info && info.version ? info.version : null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for updates after a short delay so the wizard can boot first.
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((e) => {
|
||||
// offline / network errors are expected — stay silent
|
||||
console.error('[Updater] checkForUpdates failed (likely offline):', e == null ? 'unknown' : e.message || String(e));
|
||||
});
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// IPC: renderer can manually trigger an update check
|
||||
ipcMain.handle('check-for-updates', async () => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
return { success: true, updateInfo: result && result.updateInfo ? { version: result.updateInfo.version } : null };
|
||||
} catch (e) {
|
||||
return { success: false, error: e == null ? 'unknown' : e.message || String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
// IPC: renderer can request to quit-and-install a downloaded update
|
||||
ipcMain.handle('quit-and-install', async () => {
|
||||
try {
|
||||
autoUpdater.quitAndInstall();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e == null ? 'unknown' : e.message || String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -147,26 +47,9 @@ function createWindow() {
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
createWindow();
|
||||
|
||||
// Start the auto-updater (10s delayed check, silent on failure)
|
||||
configureAutoUpdater();
|
||||
|
||||
registerVMHandlers(mainWindow);
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,511 +0,0 @@
|
||||
/**
|
||||
* 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,34 +95,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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));
|
||||
},
|
||||
|
||||
// --- Auto-updater ---
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
quitAndInstall: () => ipcRenderer.invoke('quit-and-install'),
|
||||
onUpdateAvailable: (callback) => {
|
||||
ipcRenderer.on('update-available', (event, data) => callback(data));
|
||||
},
|
||||
onUpdateDownloaded: (callback) => {
|
||||
ipcRenderer.on('update-downloaded', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
// Remove listeners
|
||||
removeListener: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* 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 = '';
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./disk-budget-step.js"></script>
|
||||
<script src="./wizard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,24 +44,12 @@ const state = {
|
||||
email: '',
|
||||
caName: 'DashCaddy Local CA'
|
||||
},
|
||||
// Disk budget / VM sandbox configuration
|
||||
diskBudget: {
|
||||
preset: 'balanced',
|
||||
diskSizeGB: 30,
|
||||
customSizeGB: 30
|
||||
},
|
||||
// Detected network IPs
|
||||
network: {
|
||||
lanIP: '',
|
||||
tailscaleIP: '',
|
||||
detected: false
|
||||
},
|
||||
// Auto-updater state
|
||||
update: {
|
||||
available: false,
|
||||
downloaded: false,
|
||||
version: null
|
||||
},
|
||||
installation: {
|
||||
status: 'pending', // pending, running, complete, error
|
||||
progress: 0,
|
||||
@@ -103,7 +91,6 @@ const steps = [
|
||||
{ id: 'welcome', title: 'Welcome' },
|
||||
{ id: 'dependencies', title: 'Dependencies' },
|
||||
{ id: 'folder', title: 'Install Path' },
|
||||
{ id: 'disk', title: 'Storage' },
|
||||
{ id: 'tier', title: 'Tier' },
|
||||
{ id: 'access', title: 'Access' },
|
||||
{ id: 'dns', title: 'DNS' },
|
||||
@@ -192,7 +179,7 @@ function setupEventListeners() {
|
||||
state.result.dashboardUrl = data.dashboardUrl;
|
||||
state.result.installPath = data.installPath;
|
||||
state.result.health = data.health || null;
|
||||
state.currentStep = 9; // Move to complete step
|
||||
state.currentStep = 8; // Move to complete step
|
||||
render();
|
||||
});
|
||||
|
||||
@@ -220,35 +207,6 @@ function setupEventListeners() {
|
||||
state.uninstall.error = data.error;
|
||||
render();
|
||||
});
|
||||
|
||||
// VM provisioning progress / error listeners
|
||||
window.electronAPI.onVMProgress((data) => {
|
||||
if (state.installation.status === 'running') {
|
||||
state.installation.progress = Math.min(data.progress || 0, 5);
|
||||
state.installation.currentTask = data.task || 'Provisioning sandboxed virtual disk...';
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
window.electronAPI.onVMError((data) => {
|
||||
state.installation.status = 'error';
|
||||
state.installation.error = data.error || 'VM provisioning failed';
|
||||
render();
|
||||
});
|
||||
|
||||
// Auto-updater listeners
|
||||
window.electronAPI.onUpdateAvailable((data) => {
|
||||
state.update.available = true;
|
||||
state.update.downloaded = false;
|
||||
state.update.version = (data && data.version) ? data.version : null;
|
||||
render();
|
||||
});
|
||||
|
||||
window.electronAPI.onUpdateDownloaded((data) => {
|
||||
state.update.downloaded = true;
|
||||
state.update.version = (data && data.version) ? data.version : state.update.version;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// Navigation
|
||||
@@ -261,7 +219,7 @@ function nextStep() {
|
||||
case 1: // Dependencies
|
||||
checkDependencies();
|
||||
break;
|
||||
case 8: // Installation
|
||||
case 7: // Installation
|
||||
startInstallation();
|
||||
break;
|
||||
}
|
||||
@@ -462,33 +420,6 @@ async function startInstallation() {
|
||||
render();
|
||||
|
||||
try {
|
||||
// ── Provision sandboxed VM disk before installation ─────────
|
||||
if (state.diskBudget && state.diskBudget.diskSizeGB) {
|
||||
state.installation.currentTask = 'Provisioning sandboxed virtual disk...';
|
||||
state.installation.progress = 1;
|
||||
render();
|
||||
|
||||
const domain = state.domainMode === 'public'
|
||||
? state.domain.publicDomain
|
||||
: state.domainMode === 'custom-tld'
|
||||
? state.domain.tld
|
||||
: null;
|
||||
|
||||
const vmResult = await window.electronAPI.vmProvision({
|
||||
diskSizeGB: state.diskBudget.diskSizeGB,
|
||||
installPath: state.paths.install,
|
||||
apiPort: state.branding.apiPort,
|
||||
domain
|
||||
});
|
||||
|
||||
if (!vmResult || !vmResult.success) {
|
||||
throw new Error((vmResult && vmResult.error) || 'VM provisioning failed');
|
||||
}
|
||||
state.installation.completedTasks.push('Virtual disk provisioned');
|
||||
state.installation.progress = 5;
|
||||
render();
|
||||
}
|
||||
|
||||
await window.electronAPI.runInstallation({
|
||||
installPath: state.paths.install,
|
||||
dockerDataPath: state.paths.dockerData,
|
||||
@@ -580,13 +511,12 @@ function renderCurrentStep() {
|
||||
case 0: return renderWelcome();
|
||||
case 1: return renderDependencies();
|
||||
case 2: return renderFolderSelection();
|
||||
case 3: return renderDiskBudgetStep();
|
||||
case 4: return renderTierSelection();
|
||||
case 5: return renderAccessMode();
|
||||
case 6: return renderDNSConfiguration();
|
||||
case 7: return renderDashboardSetup();
|
||||
case 8: return renderInstallation();
|
||||
case 9: return renderComplete();
|
||||
case 3: return renderTierSelection();
|
||||
case 4: return renderAccessMode();
|
||||
case 5: return renderDNSConfiguration();
|
||||
case 6: return renderDashboardSetup();
|
||||
case 7: return renderInstallation();
|
||||
case 8: return renderComplete();
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -1195,7 +1125,7 @@ function renderComplete() {
|
||||
function renderFooter() {
|
||||
const isFirst = state.currentStep === 0;
|
||||
const isLast = state.currentStep === steps.length - 1;
|
||||
const isInstalling = state.currentStep === 8 && state.installation.status === 'running';
|
||||
const isInstalling = state.currentStep === 7 && state.installation.status === 'running';
|
||||
|
||||
// Determine if user can proceed
|
||||
let canProceed = true;
|
||||
@@ -1206,7 +1136,7 @@ function renderFooter() {
|
||||
case 2: // Folder
|
||||
canProceed = !!state.paths.install;
|
||||
break;
|
||||
case 5: // Access mode
|
||||
case 4: // Access mode
|
||||
if (state.domainMode === 'public') {
|
||||
canProceed = !!state.domain.publicDomain && !!state.domain.email;
|
||||
} else if (state.domainMode === 'custom-tld') {
|
||||
@@ -1235,7 +1165,7 @@ function renderFooter() {
|
||||
|
||||
// Button text
|
||||
let nextLabel = 'Next';
|
||||
if (state.currentStep === 7) nextLabel = 'Install';
|
||||
if (state.currentStep === 6) nextLabel = 'Install';
|
||||
|
||||
return `
|
||||
<div class="step-footer">
|
||||
@@ -1333,26 +1263,6 @@ async function startUninstallation() {
|
||||
render();
|
||||
|
||||
try {
|
||||
// Destroy VM sandbox first (if it exists)
|
||||
if (window.electronAPI.vmDestroy && state.uninstall.config?.vmInfo) {
|
||||
state.uninstall.currentTask = 'Destroying virtual disk sandbox...';
|
||||
render();
|
||||
try {
|
||||
const vmResult = await window.electronAPI.vmDestroy({
|
||||
installPath: state.uninstall.installPath,
|
||||
vmInfo: state.uninstall.config.vmInfo,
|
||||
exportDataPath: state.uninstall.preserveSettings ? null : null
|
||||
});
|
||||
if (vmResult.success) {
|
||||
state.uninstall.completedTasks.push({ step: 'VM sandbox removed', detail: vmResult.message || 'Virtual disk deleted' });
|
||||
render();
|
||||
}
|
||||
} catch (vmErr) {
|
||||
console.warn('VM destroy failed (non-fatal):', vmErr.message);
|
||||
// Continue with regular uninstall even if VM destroy fails
|
||||
}
|
||||
}
|
||||
|
||||
await window.electronAPI.runUninstallation({
|
||||
installPath: state.uninstall.installPath,
|
||||
preserveSettings: state.uninstall.preserveSettings,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# DashCaddy Deployment Script
|
||||
# Deploys changes from Dev (E:) to Prod (C:)
|
||||
|
||||
$DevRoot = "E:\CaddyCerts\sites"
|
||||
$ProdRoot = "C:\Caddy"
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "Deploying DashCaddy Changes..." -ForegroundColor Cyan
|
||||
|
||||
# 1. Pre-deploy validation - syntax check all JS files
|
||||
Write-Host "Validating JavaScript syntax..." -ForegroundColor Yellow
|
||||
$syntaxErrors = 0
|
||||
Get-ChildItem "$DevRoot\dashcaddy-api" -Filter "*.js" | ForEach-Object {
|
||||
$result = & node -c $_.FullName 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Syntax error in $($_.Name): $result"
|
||||
$syntaxErrors++
|
||||
}
|
||||
}
|
||||
Get-ChildItem "$DevRoot\dashcaddy-api\routes" -Filter "*.js" | ForEach-Object {
|
||||
$result = & node -c $_.FullName 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Syntax error in routes/$($_.Name): $result"
|
||||
$syntaxErrors++
|
||||
}
|
||||
}
|
||||
if ($syntaxErrors -gt 0) {
|
||||
Write-Error "Aborting deploy: $syntaxErrors syntax error(s) found."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " All files pass syntax check." -ForegroundColor Green
|
||||
|
||||
# 2. Update Frontend
|
||||
Write-Host "Updating Dashboard UI..." -ForegroundColor Yellow
|
||||
if (Test-Path "$ProdRoot\sites\status") {
|
||||
# Build frontend bundles
|
||||
Write-Host "Building frontend JavaScript..." -ForegroundColor Yellow
|
||||
Set-Location "$DevRoot\status"
|
||||
& npm install
|
||||
& node build.js
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Frontend build failed!"
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Frontend build complete." -ForegroundColor Green
|
||||
|
||||
# Copy all necessary files
|
||||
Copy-Item "$DevRoot\status\index.html" "$ProdRoot\sites\status\index.html" -Force
|
||||
Copy-Item "$DevRoot\status\dist\*" "$ProdRoot\sites\status\dist\" -Force
|
||||
Set-Location $ProdRoot
|
||||
} else {
|
||||
Write-Warning "Target status folder not found. Skipping UI update."
|
||||
}
|
||||
|
||||
# 3. Update Backend API
|
||||
Write-Host "Updating API Server..." -ForegroundColor Yellow
|
||||
if (Test-Path "$ProdRoot\sites\dashcaddy-api") {
|
||||
# Copy all JS files, package files, API spec
|
||||
Get-ChildItem "$DevRoot\dashcaddy-api" -Filter "*.js" | Copy-Item -Destination "$ProdRoot\sites\dashcaddy-api\" -Force
|
||||
Copy-Item "$DevRoot\dashcaddy-api\package.json" "$ProdRoot\sites\dashcaddy-api\" -Force
|
||||
Copy-Item "$DevRoot\dashcaddy-api\package-lock.json" "$ProdRoot\sites\dashcaddy-api\" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item "$DevRoot\dashcaddy-api\openapi.yaml" "$ProdRoot\sites\dashcaddy-api\" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Copy route modules
|
||||
if (!(Test-Path "$ProdRoot\sites\dashcaddy-api\routes")) {
|
||||
New-Item -ItemType Directory -Path "$ProdRoot\sites\dashcaddy-api\routes" | Out-Null
|
||||
}
|
||||
Copy-Item "$DevRoot\dashcaddy-api\routes\*" "$ProdRoot\sites\dashcaddy-api\routes\" -Force
|
||||
|
||||
# 4. Rebuild and Restart
|
||||
Write-Host "Rebuilding API Container..." -ForegroundColor Yellow
|
||||
Set-Location $ProdRoot
|
||||
docker-compose up -d --build dashcaddy-api
|
||||
|
||||
# 5. Post-deploy health check
|
||||
Write-Host "Waiting for container startup..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 5
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "http://localhost:3001/health" -TimeoutSec 10 -ErrorAction Stop
|
||||
if ($health.status -eq 'ok') {
|
||||
Write-Host " Health check passed." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Warning "Health check returned unexpected status: $($health.status)"
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Health check failed: $_"
|
||||
Write-Warning "Check logs with: docker logs --tail 30 dashcaddy-api"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Target API folder not found. Skipping API update."
|
||||
}
|
||||
|
||||
Write-Host "Deployment Complete! Refresh your dashboard." -ForegroundColor Green
|
||||
@@ -0,0 +1,242 @@
|
||||
# SAMI-CLOUD Status Dashboard API
|
||||
|
||||
Cross-platform Node.js API server for managing Caddy reverse proxy and DNS records via REST APIs.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-Platform**: Works on Windows, Linux, and macOS
|
||||
- **API-Based**: Uses Caddy Admin API and Technitium DNS API (no PowerShell required)
|
||||
- **App Deployment**: Deploy apps by creating DNS records and Caddy reverse proxy routes
|
||||
- **App Deletion**: Clean removal of DNS records and Caddy routes
|
||||
- **Automatic Rollback**: If deployment fails, automatically rolls back changes
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Node.js** (v14 or higher)
|
||||
2. **Caddy** with Admin API enabled
|
||||
3. **Technitium DNS Server** (optional, for DNS management)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd api
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables (or use defaults):
|
||||
|
||||
```bash
|
||||
# Caddy Admin API endpoint (default: http://localhost:2019)
|
||||
export CADDY_ADMIN_API=http://localhost:2019
|
||||
|
||||
# Technitium DNS Server API endpoint (default: http://192.168.254.204:5380)
|
||||
export DNS_SERVER_API=http://192.168.254.204:5380
|
||||
|
||||
# Technitium DNS API token (required for DNS operations)
|
||||
export TECHNITIUM_API_TOKEN=your_api_token_here
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
```powershell
|
||||
$env:CADDY_ADMIN_API="http://localhost:2019"
|
||||
$env:DNS_SERVER_API="http://192.168.254.204:5380"
|
||||
$env:TECHNITIUM_API_TOKEN="your_api_token_here"
|
||||
```
|
||||
|
||||
### Windows (Command Prompt)
|
||||
```cmd
|
||||
set CADDY_ADMIN_API=http://localhost:2019
|
||||
set DNS_SERVER_API=http://192.168.254.204:5380
|
||||
set TECHNITIUM_API_TOKEN=your_api_token_here
|
||||
```
|
||||
|
||||
## Running the Server
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Or directly:
|
||||
```bash
|
||||
node caddy-api.js
|
||||
```
|
||||
|
||||
The server will start on port 3001.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Deploy an App
|
||||
```http
|
||||
POST /api/apps/deploy
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"appId": "myapp",
|
||||
"config": {
|
||||
"subdomain": "myapp",
|
||||
"ip": "192.168.1.100",
|
||||
"port": "8080",
|
||||
"createDns": true,
|
||||
"dnsType": "private",
|
||||
"sslType": "internal"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "App myapp deployed successfully",
|
||||
"url": "https://myapp.sami",
|
||||
"domain": "myapp.sami",
|
||||
"ip": "192.168.1.100",
|
||||
"port": "8080",
|
||||
"dnsCreated": true,
|
||||
"caddyConfigured": true
|
||||
}
|
||||
```
|
||||
|
||||
### Delete an App
|
||||
```http
|
||||
POST /api/apps/delete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"domain": "myapp.sami",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Services List
|
||||
```http
|
||||
GET /api/services
|
||||
```
|
||||
|
||||
### Get Caddy Configuration
|
||||
```http
|
||||
GET /api/caddy/config
|
||||
```
|
||||
|
||||
### Test API
|
||||
```http
|
||||
GET /api/caddy/test
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
## Caddy Configuration Requirements
|
||||
|
||||
Your Caddyfile should have the Admin API enabled:
|
||||
|
||||
```caddyfile
|
||||
{
|
||||
admin localhost:2019 {
|
||||
origins localhost localhost:2019
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the status dashboard to proxy API requests, add this to your Caddyfile:
|
||||
|
||||
```caddyfile
|
||||
status.sami {
|
||||
tls internal
|
||||
|
||||
# API proxy to Node.js server
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:3001
|
||||
}
|
||||
|
||||
# Static site
|
||||
root * /path/to/sites/status
|
||||
file_server
|
||||
}
|
||||
```
|
||||
|
||||
## Getting Technitium DNS API Token
|
||||
|
||||
1. Open Technitium DNS web interface
|
||||
2. Go to Settings → API
|
||||
3. Create a new API token or copy existing one
|
||||
4. Set it as the `TECHNITIUM_API_TOKEN` environment variable
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
When deploying an app:
|
||||
|
||||
1. **Validate** - Checks required fields (appId, subdomain, ip)
|
||||
2. **DNS Record** - Creates A record in DNS (if `createDns: true` and `dnsType: "private"`)
|
||||
3. **Caddy Route** - Adds reverse proxy route via Caddy Admin API
|
||||
4. **Rollback** - If Caddy configuration fails, removes DNS record
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Caddy Admin API not accessible
|
||||
- Verify Caddy is running
|
||||
- Check that admin API is enabled in your Caddyfile
|
||||
- Confirm the CADDY_ADMIN_API URL is correct
|
||||
|
||||
### DNS operations failing
|
||||
- Verify TECHNITIUM_API_TOKEN is set correctly
|
||||
- Check DNS_SERVER_API URL is accessible
|
||||
- Ensure the API token has permissions to manage zones
|
||||
|
||||
### Routes not appearing in Caddy
|
||||
- Check Caddy logs: `caddy logs`
|
||||
- Verify the route was added: `curl http://localhost:2019/config/`
|
||||
- Ensure the domain resolves correctly in DNS
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production use:
|
||||
|
||||
1. Set up environment variables persistently
|
||||
2. Use a process manager (PM2, systemd, etc.)
|
||||
3. Configure proper logging
|
||||
4. Set up SSL/TLS for the API if exposed externally
|
||||
|
||||
### Using PM2
|
||||
```bash
|
||||
npm install -g pm2
|
||||
pm2 start caddy-api.js --name sami-api
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
### Using systemd (Linux)
|
||||
Create `/etc/systemd/system/sami-api.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SAMI-CLOUD API Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=caddy
|
||||
WorkingDirectory=/path/to/sites/status/api
|
||||
Environment="CADDY_ADMIN_API=http://localhost:2019"
|
||||
Environment="DNS_SERVER_API=http://192.168.254.204:5380"
|
||||
Environment="TECHNITIUM_API_TOKEN=your_token"
|
||||
ExecStart=/usr/bin/node caddy-api.js
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
sudo systemctl enable sami-api
|
||||
sudo systemctl start sami-api
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,362 @@
|
||||
// Cross-platform Node.js API server for Caddy management
|
||||
// Uses Caddy Admin API and Technitium DNS API directly
|
||||
// Run with: node caddy-api.js
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Configuration
|
||||
const CADDY_ADMIN_API = process.env.CADDY_ADMIN_API || 'http://localhost:2019';
|
||||
const DNS_SERVER_API = process.env.DNS_SERVER_API || 'http://192.168.254.204:5380';
|
||||
const DNS_API_TOKEN = process.env.TECHNITIUM_API_TOKEN || '';
|
||||
|
||||
// Helper function to make HTTP requests
|
||||
async function makeRequest(url, options = {}) {
|
||||
const https = url.startsWith('https:') ? require('https') : require('http');
|
||||
const urlObj = new URL(url);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reqOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
...options
|
||||
};
|
||||
|
||||
const req = https.request(reqOptions, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
resolve({ status: res.statusCode, data: parsed });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (options.body) {
|
||||
req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Get current Caddy configuration
|
||||
app.get('/api/v1/caddy/config', async (req, res) => {
|
||||
try {
|
||||
const response = await makeRequest(`${CADDY_ADMIN_API}/config/`);
|
||||
|
||||
if (response.status === 200) {
|
||||
res.json({
|
||||
status: 'success',
|
||||
config: response.data
|
||||
});
|
||||
} else {
|
||||
res.status(response.status).json({
|
||||
status: 'error',
|
||||
message: 'Failed to get Caddy configuration',
|
||||
details: response.data
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting Caddy config:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of services (from apps.json + custom apps)
|
||||
app.get('/api/v1/services', async (req, res) => {
|
||||
try {
|
||||
const servicesPath = path.join(__dirname, '../apps.json');
|
||||
|
||||
if (fs.existsSync(servicesPath)) {
|
||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||
const services = JSON.parse(servicesData);
|
||||
res.json({ status: 'success', services });
|
||||
} else {
|
||||
res.json({ status: 'success', services: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading services:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add DNS record via Technitium API
|
||||
async function addDnsRecord(domain, ipAddress, ttl = 3600) {
|
||||
if (!DNS_API_TOKEN) {
|
||||
throw new Error('DNS API token not configured. Set TECHNITIUM_API_TOKEN environment variable.');
|
||||
}
|
||||
|
||||
const url = `${DNS_SERVER_API}/api/zones/records/add?token=${DNS_API_TOKEN}&domain=${domain}&type=A&ipAddress=${ipAddress}&ttl=${ttl}`;
|
||||
|
||||
console.log('Adding DNS record:', { domain, ipAddress, ttl });
|
||||
const response = await makeRequest(url);
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
return { success: true, message: 'DNS record added successfully' };
|
||||
} else {
|
||||
throw new Error(`DNS API error: ${response.data.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete DNS record via Technitium API
|
||||
async function deleteDnsRecord(domain, ipAddress) {
|
||||
if (!DNS_API_TOKEN) {
|
||||
console.warn('DNS API token not configured. Skipping DNS deletion.');
|
||||
return { success: true, message: 'DNS deletion skipped (no token)' };
|
||||
}
|
||||
|
||||
const url = `${DNS_SERVER_API}/api/zones/records/delete?token=${DNS_API_TOKEN}&domain=${domain}&type=A&ipAddress=${ipAddress}`;
|
||||
|
||||
console.log('Deleting DNS record:', { domain, ipAddress });
|
||||
const response = await makeRequest(url);
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
return { success: true, message: 'DNS record deleted successfully' };
|
||||
} else {
|
||||
throw new Error(`DNS API error: ${response.data.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add route to Caddy via Admin API
|
||||
async function addCaddyRoute(domain, upstreamUrl, useTls = true) {
|
||||
// Build Caddy route configuration
|
||||
const routeConfig = {
|
||||
"@id": domain,
|
||||
"match": [{
|
||||
"host": [domain]
|
||||
}],
|
||||
"handle": [{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": [{
|
||||
"dial": upstreamUrl.replace(/^https?:\/\//, '')
|
||||
}]
|
||||
}],
|
||||
"terminal": true
|
||||
};
|
||||
|
||||
// If using internal TLS, we need to add TLS configuration
|
||||
if (useTls) {
|
||||
// Caddy handles TLS automatically for matched domains
|
||||
// Internal CA is configured in the global Caddyfile
|
||||
}
|
||||
|
||||
console.log('Adding Caddy route:', JSON.stringify(routeConfig, null, 2));
|
||||
|
||||
// Add the route to the HTTP server
|
||||
const response = await makeRequest(`${CADDY_ADMIN_API}/config/apps/http/servers/srv0/routes/0`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(routeConfig)
|
||||
});
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
return { success: true, message: 'Caddy route added successfully' };
|
||||
} else {
|
||||
throw new Error(`Caddy API error: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy app endpoint - handles DNS and Caddy configuration via APIs
|
||||
app.post('/api/v1/apps/deploy', async (req, res) => {
|
||||
try {
|
||||
const { appId, config } = req.body;
|
||||
const { subdomain, ip, createDns, port, sslType, dnsType } = config;
|
||||
|
||||
console.log('Deploying app:', { appId, config });
|
||||
|
||||
// Validate required fields
|
||||
if (!appId || !subdomain || !ip) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: appId, subdomain, ip'
|
||||
});
|
||||
}
|
||||
|
||||
// Build the full domain
|
||||
const domain = subdomain.includes('.') ? subdomain : `${subdomain}.sami`;
|
||||
const finalPort = port || '80';
|
||||
const upstreamUrl = `${ip}:${finalPort}`;
|
||||
|
||||
// Step 1: Add DNS record if requested (private DNS)
|
||||
if (createDns && dnsType === 'private') {
|
||||
try {
|
||||
await addDnsRecord(domain, ip);
|
||||
} catch (dnsError) {
|
||||
console.error('DNS creation failed:', dnsError);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `DNS creation failed: ${dnsError.message}`,
|
||||
step: 'dns'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Add route to Caddy via Admin API
|
||||
try {
|
||||
const useTls = sslType === 'internal';
|
||||
await addCaddyRoute(domain, upstreamUrl, useTls);
|
||||
} catch (caddyError) {
|
||||
console.error('Caddy route addition failed:', caddyError);
|
||||
|
||||
// Rollback DNS if it was created
|
||||
if (createDns && dnsType === 'private') {
|
||||
try {
|
||||
await deleteDnsRecord(domain, ip);
|
||||
} catch (rollbackError) {
|
||||
console.error('DNS rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `Caddy configuration failed: ${caddyError.message}`,
|
||||
step: 'caddy'
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Return success response
|
||||
res.json({
|
||||
success: true,
|
||||
message: `App ${appId} deployed successfully`,
|
||||
url: `https://${domain}`,
|
||||
domain: domain,
|
||||
ip: ip,
|
||||
port: finalPort,
|
||||
containerId: null,
|
||||
dnsCreated: createDns && dnsType === 'private',
|
||||
caddyConfigured: true
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Deployment error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete app endpoint - removes DNS and Caddy configuration
|
||||
app.post('/api/v1/apps/delete', async (req, res) => {
|
||||
try {
|
||||
const { domain, ip } = req.body;
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Domain is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Deleting app:', { domain, ip });
|
||||
|
||||
// Step 1: Remove from Caddy
|
||||
try {
|
||||
const response = await makeRequest(`${CADDY_ADMIN_API}/id/${domain}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.warn('Caddy route deletion warning:', response.data);
|
||||
}
|
||||
} catch (caddyError) {
|
||||
console.error('Caddy route deletion failed:', caddyError);
|
||||
// Continue anyway to try DNS deletion
|
||||
}
|
||||
|
||||
// Step 2: Remove DNS record if IP provided
|
||||
if (ip) {
|
||||
try {
|
||||
await deleteDnsRecord(domain, ip);
|
||||
} catch (dnsError) {
|
||||
console.error('DNS deletion failed:', dnsError);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: `DNS deletion failed: ${dnsError.message}`,
|
||||
caddyDeleted: true,
|
||||
dnsDeleted: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'App deleted successfully',
|
||||
domain: domain,
|
||||
caddyDeleted: true,
|
||||
dnsDeleted: !!ip
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Deletion error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Test endpoint
|
||||
app.get('/api/v1/caddy/test', (req, res) => {
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: 'Caddy API is running',
|
||||
timestamp: new Date().toISOString(),
|
||||
platform: process.platform,
|
||||
caddyAdminApi: CADDY_ADMIN_API,
|
||||
dnsServerApi: DNS_SERVER_API,
|
||||
dnsTokenConfigured: !!DNS_API_TOKEN
|
||||
});
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n====================================`);
|
||||
console.log(`Caddy API server running on http://localhost:${PORT}`);
|
||||
console.log(`====================================`);
|
||||
console.log(`Caddy Admin API: ${CADDY_ADMIN_API}`);
|
||||
console.log(`DNS Server API: ${DNS_SERVER_API}`);
|
||||
console.log(`DNS Token: ${DNS_API_TOKEN ? '✓ Configured' : '✗ Not configured'}`);
|
||||
console.log(`\nEndpoints:`);
|
||||
console.log(` POST /api/apps/deploy - Deploy an app (DNS + Caddy)`);
|
||||
console.log(` POST /api/apps/delete - Delete an app (DNS + Caddy)`);
|
||||
console.log(` GET /api/services - Get list of services`);
|
||||
console.log(` GET /api/caddy/config - Get current Caddy configuration`);
|
||||
console.log(` GET /api/caddy/test - Test API connectivity`);
|
||||
console.log(` GET /health - Health check`);
|
||||
console.log(`====================================\n`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,206 @@
|
||||
# Caddy Configuration Manager for Windows
|
||||
# This script adds new service configurations to your Caddyfile
|
||||
|
||||
param(
|
||||
[string]$Config,
|
||||
[string]$Subdomain,
|
||||
[string]$CaddyfilePath = "C:\caddy\Caddyfile",
|
||||
[bool]$ReloadCaddy = $true
|
||||
)
|
||||
|
||||
# Function to write JSON response
|
||||
function Write-JsonResponse {
|
||||
param($Status, $Message, $Data = $null)
|
||||
|
||||
$response = @{
|
||||
status = $Status
|
||||
message = $Message
|
||||
timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
if ($Data) {
|
||||
$response.data = $Data
|
||||
}
|
||||
|
||||
return $response | ConvertTo-Json
|
||||
}
|
||||
|
||||
# Function to extract CA names from Caddyfile
|
||||
function Get-CaddyfileCAs {
|
||||
param([string]$CaddyfilePath)
|
||||
|
||||
try {
|
||||
Write-Host "DEBUG: Checking file path: $CaddyfilePath"
|
||||
|
||||
if (-not (Test-Path $CaddyfilePath)) {
|
||||
Write-Host "DEBUG: File not found"
|
||||
return @()
|
||||
}
|
||||
|
||||
$content = Get-Content $CaddyfilePath -Raw
|
||||
Write-Host "DEBUG: File content length: $($content.Length)"
|
||||
Write-Host "DEBUG: First 200 chars: $($content.Substring(0, [Math]::Min(200, $content.Length)))"
|
||||
|
||||
$caNames = @()
|
||||
|
||||
# Pattern 1: PKI block with CA definitions - pki { ca ca_name { name "Friendly Name" } }
|
||||
$pkiPattern = 'pki\s*\{[^}]*?ca\s+([^\s\{]+)\s*\{([^}]*?)\}'
|
||||
Write-Host "DEBUG: Searching for PKI pattern: $pkiPattern"
|
||||
|
||||
$pkiMatches = [regex]::Matches($content, $pkiPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||
Write-Host "DEBUG: PKI matches found: $($pkiMatches.Count)"
|
||||
|
||||
foreach ($match in $pkiMatches) {
|
||||
$caId = $match.Groups[1].Value
|
||||
$caBlock = $match.Groups[2].Value
|
||||
Write-Host "DEBUG: Found CA ID: $caId"
|
||||
Write-Host "DEBUG: CA Block: $caBlock"
|
||||
|
||||
# Try to extract the friendly name from within the CA block
|
||||
$nameMatch = [regex]::Match($caBlock, 'name\s+"([^"]+)"', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
if ($nameMatch.Success) {
|
||||
$friendlyName = $nameMatch.Groups[1].Value
|
||||
Write-Host "DEBUG: Found friendly name: $friendlyName"
|
||||
$caNames += "$caId ($friendlyName)"
|
||||
} else {
|
||||
Write-Host "DEBUG: No friendly name found, using ID only"
|
||||
$caNames += $caId
|
||||
}
|
||||
}
|
||||
|
||||
# Pattern 2: tls { ca ca_name }
|
||||
$matches1 = [regex]::Matches($content, 'tls\s*\{\s*ca\s+([^\s\}]+)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
Write-Host "DEBUG: TLS block matches: $($matches1.Count)"
|
||||
foreach ($match in $matches1) {
|
||||
$caNames += $match.Groups[1].Value
|
||||
}
|
||||
|
||||
# Pattern 3: tls ca_name (direct)
|
||||
$matches2 = [regex]::Matches($content, 'tls\s+([^\s\{]+)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
Write-Host "DEBUG: Direct TLS matches: $($matches2.Count)"
|
||||
foreach ($match in $matches2) {
|
||||
$caName = $match.Groups[1].Value
|
||||
if ($caName -ne "internal" -and $caName -ne "off") {
|
||||
$caNames += $caName
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "DEBUG: Total CAs found: $($caNames.Count)"
|
||||
Write-Host "DEBUG: CA list: $($caNames -join ', ')"
|
||||
|
||||
# Remove duplicates and return
|
||||
return $caNames | Sort-Object | Get-Unique
|
||||
}
|
||||
catch {
|
||||
Write-Host "DEBUG: Exception in Get-CaddyfileCAs: $($_.Exception.Message)"
|
||||
Write-Host "Error reading CAs from Caddyfile: $($_.Exception.Message)"
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
# Handle get-cas command
|
||||
if ($args[0] -eq "get-cas") {
|
||||
$CaddyfilePath = if ($args[1]) { $args[1] } else { "C:\caddy\Caddyfile" }
|
||||
|
||||
Write-Host "DEBUG: get-cas command received"
|
||||
Write-Host "DEBUG: Caddyfile path: $CaddyfilePath"
|
||||
Write-Host "DEBUG: File exists: $(Test-Path $CaddyfilePath)"
|
||||
|
||||
try {
|
||||
$cas = Get-CaddyfileCAs -CaddyfilePath $CaddyfilePath
|
||||
|
||||
Write-Host "DEBUG: CAs found: $($cas -join ', ')"
|
||||
|
||||
$response = @{
|
||||
status = "success"
|
||||
message = "CAs retrieved successfully"
|
||||
data = @{
|
||||
cas = $cas
|
||||
count = $cas.Count
|
||||
caddyfilePath = $CaddyfilePath
|
||||
}
|
||||
timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
Write-Output ($response | ConvertTo-Json)
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "DEBUG: Error occurred: $($_.Exception.Message)"
|
||||
|
||||
$response = @{
|
||||
status = "error"
|
||||
message = "Failed to retrieve CAs: $($_.Exception.Message)"
|
||||
timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
Write-Output ($response | ConvertTo-Json)
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Main configuration addition logic
|
||||
try {
|
||||
# Validate required parameters for config addition
|
||||
if (-not $Config -or -not $Subdomain) {
|
||||
Write-Output (Write-JsonResponse "error" "Config and Subdomain parameters are required")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if Caddyfile exists
|
||||
if (-not (Test-Path $CaddyfilePath)) {
|
||||
Write-Output (Write-JsonResponse "error" "Caddyfile not found at: $CaddyfilePath")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Read existing Caddyfile
|
||||
$existingConfig = Get-Content $CaddyfilePath -Raw -ErrorAction Stop
|
||||
|
||||
# Check if subdomain already exists
|
||||
if ($existingConfig -match "$Subdomain\.sami\s*\{") {
|
||||
Write-Output (Write-JsonResponse "error" "Subdomain '$Subdomain.sami' already exists in Caddyfile")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Create backup
|
||||
$backupPath = "$CaddyfilePath.backup.$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
Copy-Item $CaddyfilePath $backupPath -ErrorAction Stop
|
||||
Write-Host "Backup created: $backupPath"
|
||||
|
||||
# Append new configuration
|
||||
$newContent = $existingConfig.TrimEnd() + "`n`n" + $Config.TrimEnd() + "`n"
|
||||
Set-Content -Path $CaddyfilePath -Value $newContent -NoNewline -ErrorAction Stop
|
||||
|
||||
Write-Host "Configuration added successfully"
|
||||
|
||||
# Reload Caddy if requested
|
||||
if ($ReloadCaddy) {
|
||||
Write-Host "Reloading Caddy..."
|
||||
|
||||
# Try to reload Caddy
|
||||
$reloadResult = & caddy reload --config $CaddyfilePath 2>&1
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Caddy reloaded successfully"
|
||||
Write-Output (Write-JsonResponse "success" "Configuration added and Caddy reloaded successfully" @{
|
||||
backup = $backupPath
|
||||
subdomain = "$Subdomain.sami"
|
||||
})
|
||||
} else {
|
||||
Write-Host "Caddy reload failed: $reloadResult"
|
||||
# Restore backup if reload failed
|
||||
Copy-Item $backupPath $CaddyfilePath -Force
|
||||
Write-Output (Write-JsonResponse "error" "Caddy reload failed. Configuration rolled back. Error: $reloadResult")
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Output (Write-JsonResponse "success" "Configuration added successfully (Caddy not reloaded)" @{
|
||||
backup = $backupPath
|
||||
subdomain = "$Subdomain.sami"
|
||||
})
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Output (Write-JsonResponse "error" "Error: $($_.Exception.Message)")
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@echo off
|
||||
title SAMI Caddy API Server
|
||||
echo ========================================
|
||||
echo Installing SAMI Caddy API Server...
|
||||
echo ========================================
|
||||
|
||||
REM Check if Node.js is installed
|
||||
echo Checking for Node.js...
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: Node.js is not installed or not in PATH
|
||||
echo Please install Node.js from https://nodejs.org/
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Node.js found:
|
||||
node --version
|
||||
|
||||
echo.
|
||||
echo Installing dependencies...
|
||||
echo.
|
||||
|
||||
REM Install npm dependencies
|
||||
npm install
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: Failed to install dependencies
|
||||
echo Check the error messages above
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Dependencies installed successfully!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Starting Caddy API server...
|
||||
echo.
|
||||
echo Server URL: http://localhost:3001
|
||||
echo Test URL: http://localhost:3001/api/caddy/test
|
||||
echo.
|
||||
echo Press Ctrl+C to stop the server
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Start the server and keep window open on error
|
||||
npm start
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ERROR: Server failed to start
|
||||
echo Check the error messages above
|
||||
echo.
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Server stopped.
|
||||
pause
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "sami-caddy-api",
|
||||
"version": "2.0.0",
|
||||
"description": "Cross-platform API server for managing Caddy and DNS via REST APIs",
|
||||
"main": "caddy-api.js",
|
||||
"scripts": {
|
||||
"start": "node caddy-api.js",
|
||||
"dev": "nodemon caddy-api.js",
|
||||
"test": "node test-api.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
},
|
||||
"keywords": ["caddy", "api", "dns", "technitium", "reverse-proxy", "cross-platform", "sami-cloud"],
|
||||
"author": "SAMI-CLOUD",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@echo off
|
||||
REM Quick start script for SAMI-CLOUD API Server (Windows)
|
||||
|
||||
echo ====================================
|
||||
echo SAMI-CLOUD API Server
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
REM Check if Node.js is installed
|
||||
where node >nul 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Node.js is not installed or not in PATH
|
||||
echo Please install Node.js from https://nodejs.org/
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Check if node_modules exists
|
||||
if not exist "node_modules" (
|
||||
echo Installing dependencies...
|
||||
call npm install
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Check environment variables
|
||||
if "%CADDY_ADMIN_API%"=="" (
|
||||
echo Warning: CADDY_ADMIN_API not set, using default: http://localhost:2019
|
||||
set CADDY_ADMIN_API=http://localhost:2019
|
||||
)
|
||||
|
||||
if "%DNS_SERVER_API%"=="" (
|
||||
echo Warning: DNS_SERVER_API not set, using default: http://192.168.254.204:5380
|
||||
set DNS_SERVER_API=http://192.168.254.204:5380
|
||||
)
|
||||
|
||||
if "%TECHNITIUM_API_TOKEN%"=="" (
|
||||
echo Warning: TECHNITIUM_API_TOKEN not set - DNS operations will fail
|
||||
echo Set it with: set TECHNITIUM_API_TOKEN=your_token
|
||||
echo.
|
||||
)
|
||||
|
||||
echo Starting API server...
|
||||
echo.
|
||||
node caddy-api.js
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Quick start script for SAMI-CLOUD API Server (Linux/macOS)
|
||||
|
||||
echo "===================================="
|
||||
echo "SAMI-CLOUD API Server"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Check if Node.js is installed
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "Error: Node.js is not installed or not in PATH"
|
||||
echo "Please install Node.js from https://nodejs.org/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if node_modules exists
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "Installing dependencies..."
|
||||
npm install
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check environment variables
|
||||
if [ -z "$CADDY_ADMIN_API" ]; then
|
||||
echo "Warning: CADDY_ADMIN_API not set, using default: http://localhost:2019"
|
||||
export CADDY_ADMIN_API="http://localhost:2019"
|
||||
fi
|
||||
|
||||
if [ -z "$DNS_SERVER_API" ]; then
|
||||
echo "Warning: DNS_SERVER_API not set, using default: http://192.168.254.204:5380"
|
||||
export DNS_SERVER_API="http://192.168.254.204:5380"
|
||||
fi
|
||||
|
||||
if [ -z "$TECHNITIUM_API_TOKEN" ]; then
|
||||
echo "Warning: TECHNITIUM_API_TOKEN not set - DNS operations will fail"
|
||||
echo "Set it with: export TECHNITIUM_API_TOKEN=your_token"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Starting API server..."
|
||||
echo ""
|
||||
node caddy-api.js
|
||||
@@ -0,0 +1,72 @@
|
||||
// Simple test script to verify API connectivity
|
||||
const http = require('http');
|
||||
|
||||
const API_URL = 'http://localhost:3001';
|
||||
|
||||
function makeRequest(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`${API_URL}${path}`, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: data });
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('Testing SAMI-CLOUD API...\n');
|
||||
|
||||
// Test 1: Health Check
|
||||
console.log('1. Testing health endpoint...');
|
||||
try {
|
||||
const health = await makeRequest('/health');
|
||||
if (health.status === 200) {
|
||||
console.log(' ✓ Health check passed');
|
||||
} else {
|
||||
console.log(' ✗ Health check failed:', health.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ✗ Health check error:', error.message);
|
||||
}
|
||||
|
||||
// Test 2: API Test Endpoint
|
||||
console.log('\n2. Testing API test endpoint...');
|
||||
try {
|
||||
const test = await makeRequest('/api/v1/caddy/test');
|
||||
if (test.status === 200) {
|
||||
console.log(' ✓ API test passed');
|
||||
console.log(' Platform:', test.data.platform);
|
||||
console.log(' Caddy Admin API:', test.data.caddyAdminApi);
|
||||
console.log(' DNS Server API:', test.data.dnsServerApi);
|
||||
console.log(' DNS Token:', test.data.dnsTokenConfigured ? 'Configured' : 'Not configured');
|
||||
} else {
|
||||
console.log(' ✗ API test failed:', test.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ✗ API test error:', error.message);
|
||||
}
|
||||
|
||||
// Test 3: Services Endpoint
|
||||
console.log('\n3. Testing services endpoint...');
|
||||
try {
|
||||
const services = await makeRequest('/api/v1/services');
|
||||
if (services.status === 200) {
|
||||
console.log(' ✓ Services endpoint passed');
|
||||
console.log(' Found', services.data.services.length, 'services');
|
||||
} else {
|
||||
console.log(' ✗ Services endpoint failed:', services.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ✗ Services endpoint error:', error.message);
|
||||
}
|
||||
|
||||
console.log('\nTests complete!');
|
||||
}
|
||||
|
||||
runTests().catch(console.error);
|
||||
@@ -0,0 +1,84 @@
|
||||
@echo off
|
||||
title SAMI Caddy API Server - Debug Mode
|
||||
echo ========================================
|
||||
echo SAMI Caddy API Server - Debug Mode
|
||||
echo ========================================
|
||||
|
||||
cd /d "%~dp0"
|
||||
echo Current directory: %CD%
|
||||
|
||||
echo.
|
||||
echo Checking Node.js...
|
||||
node --version
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: Node.js not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Checking files...
|
||||
if exist "caddy-api.js" (
|
||||
echo ✓ caddy-api.js found
|
||||
) else (
|
||||
echo ✗ caddy-api.js NOT found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if exist "package.json" (
|
||||
echo ✓ package.json found
|
||||
) else (
|
||||
echo ✗ package.json NOT found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if exist "caddy-manager.ps1" (
|
||||
echo ✓ caddy-manager.ps1 found
|
||||
) else (
|
||||
echo ✗ caddy-manager.ps1 NOT found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installing dependencies...
|
||||
npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: npm install failed
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Starting server with error capture...
|
||||
echo ========================================
|
||||
echo Server will run on: http://localhost:3001
|
||||
echo Test endpoint: http://localhost:3001/api/caddy/test
|
||||
echo.
|
||||
echo If server starts successfully, you'll see "Caddy API server running..."
|
||||
echo Press Ctrl+C to stop the server
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Capture both stdout and stderr
|
||||
node caddy-api.js 2>&1
|
||||
set SERVER_EXIT_CODE=%errorlevel%
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Server exited with code: %SERVER_EXIT_CODE%
|
||||
echo ========================================
|
||||
|
||||
if %SERVER_EXIT_CODE% neq 0 (
|
||||
echo ERROR: Server failed to start or crashed
|
||||
echo Check the error messages above
|
||||
) else (
|
||||
echo Server stopped normally
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Press any key to close this window...
|
||||
pause >nul
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 167 KiB |
@@ -77,6 +77,11 @@ const bundles = {
|
||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||
// defined in globals.js (already in core.js).
|
||||
JS('share-modal.js'),
|
||||
// DC-106: Reverse proxy visual builder — opened from the "🔧 Reverse
|
||||
// Proxy Builder" button in the Tools menu. Uses window.injectModal +
|
||||
// window.escapeHtml from globals.js (in core.js). Lives in features.js
|
||||
// because it's a modal-style tool, not part of the core dashboard.
|
||||
JS('caddy-builder.js'),
|
||||
],
|
||||
'onboarding.js': [
|
||||
JS('driver.min.js'),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/* ===== DC-106: Reverse Proxy Builder styles ===== */
|
||||
|
||||
.cb-section {
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--card-base, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cb-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-label > input[type="text"],
|
||||
.cb-label > select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cb-label > input[type="text"]:focus,
|
||||
.cb-label > select:focus {
|
||||
outline: 2px solid var(--accent, #4a9eff);
|
||||
outline-offset: -1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cb-label > input[type="text"]:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cb-label small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cb-label small code {
|
||||
background: var(--code-bg, rgba(0,0,0,0.06));
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.cb-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-checkbox input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-preview {
|
||||
flex: 1;
|
||||
min-height: 380px;
|
||||
max-height: 540px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: var(--code-bg, #0e1116);
|
||||
color: var(--code-fg, #e6e6e6);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.cb-preview code {
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cb-validation {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cb-issues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cb-err {
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--err-fg, #e74c3c) 12%, transparent);
|
||||
border-left: 3px solid var(--err-fg, #e74c3c);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-warn {
|
||||
padding: 6px 10px;
|
||||
background: color-mix(in srgb, var(--warn-fg, #f0c674) 12%, transparent);
|
||||
border-left: 3px solid var(--warn-fg, #f0c674);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-ok {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: color-mix(in srgb, var(--ok-fg, #27ae60) 12%, transparent);
|
||||
border-left: 3px solid var(--ok-fg, #27ae60);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.cb-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--err-fg, #e74c3c) 12%, transparent);
|
||||
border-left: 3px solid var(--err-fg, #e74c3c);
|
||||
border-radius: 4px;
|
||||
color: var(--fg);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cb-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cb-header-row input {
|
||||
padding: 5px 8px;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cb-header-row input:focus {
|
||||
outline: 2px solid var(--accent, #4a9eff);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.cb-header-row button {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cb-header-row button:hover {
|
||||
border-color: var(--err-fg, #e74c3c);
|
||||
color: var(--err-fg, #e74c3c);
|
||||
}
|
||||
|
||||
/* Modal sizing override for the builder — needs wider content */
|
||||
#caddy-builder-modal .weather-modal-content {
|
||||
width: min(1100px, 95vw);
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
#caddy-builder-modal .weather-modal-content > div[style*="grid-template-columns"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
(function(l){"use strict";class o{constructor(){this.errors=[],this.maxErrors=50}logError(h,f,m={}){const b={timestamp:new Date().toISOString(),context:h,message:f instanceof Error?f.message:f,stack:f instanceof Error?f.stack:null,metadata:m};this.errors.push(b),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,f,m)}recoverFromError(h,f){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:f}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const f=h.message||h.toString();return f.includes("element")&&f.includes("not found")?"ELEMENT_NOT_FOUND":f.includes("storage")||f.includes("quota")?"STORAGE_UNAVAILABLE":f.includes("driver")||f.includes("undefined")?"DRIVER_NOT_LOADED":f.includes("invalid")||f.includes("validation")?"INVALID_TOOLTIP":f.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(f=>{h.byContext[f.context]=(h.byContext[f.context]||0)+1;const m=this.classifyError({message:f.message});h.byType[m]=(h.byType[m]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
|
||||
(function(c){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,g,m={}){const b={timestamp:new Date().toISOString(),context:h,message:g instanceof Error?g.message:g,stack:g instanceof Error?g.stack:null,metadata:m};this.errors.push(b),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,g,m)}recoverFromError(h,g){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:g}),{action:"SKIP_STEP",nextStep:g+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:g}),{action:"SKIP_STEP",nextStep:g+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:g}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const g=h.message||h.toString();return g.includes("element")&&g.includes("not found")?"ELEMENT_NOT_FOUND":g.includes("storage")||g.includes("quota")?"STORAGE_UNAVAILABLE":g.includes("driver")||g.includes("undefined")?"DRIVER_NOT_LOADED":g.includes("invalid")||g.includes("validation")?"INVALID_TOOLTIP":g.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(g=>{h.byContext[g.context]=(h.byContext[g.context]||0)+1;const m=this.classifyError({message:g.message});h.byType[m]=(h.byType[m]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
@@ -16,14 +16,14 @@
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(f){return this.data[f]||null},setItem(f,m){this.data[f]=m},removeItem(f){delete this.data[f]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}l.ErrorHandler=o,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var o=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(o){var g=JSON.parse(o);g.statsPollingInterval&&g.statsPollingInterval>=5&&g.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=g.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const f=await h.json();if(f.tld&&(SITE.tld=f.tld.startsWith(".")?f.tld:"."+f.tld),f.dns&&(SITE.dnsIp=f.dns.ip||"",SITE.dnsPort=f.dns.port||DC.DEFAULTS.DNS_PORT),f.dnsServers&&typeof f.dnsServers=="object")for(const[b,r]of Object.entries(f.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=r);f.configurationType&&(SITE.configurationType=f.configurationType),f.domain&&(SITE.domain=f.domain),f.defaults&&(SITE.defaults=f.defaults),f.routingMode&&(SITE.routingMode=f.routingMode),SITE.onboardingCompleted=f.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const m=document.getElementById("manage-tokens");m&&(m.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const o=document.getElementById("edit-tld-suffix");o&&(o.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const o=SITE.dnsServers[l];return o?`${o.ip}:${o.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,o]of Object.entries(SITE.dnsServers))if(o.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)return;const o=Object.keys(SITE.dnsServers);if(!o.length)return;const g='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=l.firstElementChild;o.forEach(f=>{const m=escapeHtml(f),b=escapeHtml((SITE.dnsServers[f].name||f).toUpperCase()),r=document.createElement("div");r.className="card",r.setAttribute("data-app",f),r.setAttribute("data-status","off"),r.innerHTML=`<span id="${m}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${g}</div><span class="name">${b}</span><span class="spacer"></span><span id="${m}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${m}-time" class="response-time">--</span></div><div class="health-row" id="health-${m}"><span id="uptime-${m}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${m}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${m}-restart" class="restart-btn">Restart</button><button id="${m}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${m}-open">Open</button><button id="${m}-logs" class="logs-btn">Logs</button><button id="${m}-sLine truncated
|
||||
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(g){return this.data[g]||null},setItem(g,m){this.data[g]=m},removeItem(g){delete this.data[g]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const g=await h.json();if(g.tld&&(SITE.tld=g.tld.startsWith(".")?g.tld:"."+g.tld),g.dns&&(SITE.dnsIp=g.dns.ip||"",SITE.dnsPort=g.dns.port||DC.DEFAULTS.DNS_PORT),g.dnsServers&&typeof g.dnsServers=="object")for(const[b,r]of Object.entries(g.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=r);g.configurationType&&(SITE.configurationType=g.configurationType),g.domain&&(SITE.domain=g.domain),g.defaults&&(SITE.defaults=g.defaults),g.routingMode&&(SITE.routingMode=g.routingMode),SITE.onboardingCompleted=g.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const m=document.getElementById("manage-tokens");m&&(m.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const f=document.getElementById("external-proxy-ip");f&&SITE.dnsIp&&(f.value=SITE.dnsIp,f.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const a=SITE.dnsServers[c];return a?`${a.ip}:${a.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const f='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;a.forEach(g=>{const m=escapeHtml(g),b=escapeHtml((SITE.dnsServers[g].name||g).toUpperCase()),r=document.createElement("div");r.className="card",r.setAttribute("data-app",g),r.setAttribute("data-status","off"),r.innerHTML=`<span id="${m}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${f}</div><span class="name">${b}</span><span class="spacer"></span><span id="${m}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${m}-time" class="response-time">--</span></div><div class="health-row" id="health-${m}"><span id="uptime-${m}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${m}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${m}-restart" class="restart-btn">Restart</button><button id="${m}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${m}-open">Open</button><button id="${m}-logs" class="logs-btn">Logs</button><button id="${m}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,c.insertBefore(r,h)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const c=await fetch("/api/v1/csrf-token");if(!c.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await c.jsLine truncated
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${m.bg}; color: ${m.fg};
|
||||
padding: 16px 24px; border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 10000; animation: slideIn 0.3s ease-out;
|
||||
max-width: 400px; white-space: pre-line; font-size: 14px;
|
||||
`,document.body.appendChild(b),g>0&&setTimeout(()=>b.remove(),g)}function timeAgo(l){const o=Date.now()-new Date(l).getTime();return o<6e4?"just now":o<36e5?Math.floor(o/6e4)+"m ago":o<864e5?Math.floor(o/36e5)+"h ago":Math.floor(o/864e5)+"d ago"}function safeGet(l,o=null){try{const g=localStorage.getItem(l);return g!==null?g:o}catch{return o}}function safeSet(l,o){try{localStorage.setItem(l,o)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,o=null){try{const g=sessionStorage.getItem(l);return g!==null?g:o}catch{return o}}function safeSessionSet(l,o){try{sessionStorage.setItem(l,o)}catch{}}function safeGetJSON(l,o=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):o}catch{return o}}function escapeHtml(l){return String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(l,o){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",o)}const DC_BUS={_handlers:{},on(l,o){var g;((g=this._handlers)[l]||(g[l]=[])).push(o)},off(l,o){this._handlers[l]=this._handlers[l]?.filter(g=>g!==o)},emit(l,o){this._handlers[l]?.forEach(g=>g(o))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(o=>o.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const o=this._apps.findIndex(g=>g.id===l);return o>-1&&(this._apps.splice(o,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),o>-1},updateApp(l,o){const g=this._apps.find(h=>h.id===l);if(g){for(const[h,f]of Object.entries(o))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(g[h]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function o(h){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){h=h||6;for(let m=0;m<h;m++){const b=l();f.appendChild(b),setTimeout(function(){b.classList.add("loaded")},m*60)}}}function g(){const h=document.getElementById("cards");if(h){var f=h.querySelectorAll(".skeleton-card");f.length&&(f.forEach(function(m,b){setTimeout(function(){m.style.opacity="0",m.style.transform="translateY(-10px)"},b*25)}),setTimeout(function(){f.forEach(function(m){m.parentNode&&m.remove()})},f.length*25+300))}}window.SkeletonLoader={show:o,hide:g}})(),(function(){var l="theme",o="user-themes",g="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],f=h.slice(),m=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],b=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],r=["fg-muted","hover","card-hover","base","success","error","warning"],v={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0",
Line truncated
|
||||
`,document.body.appendChild(b),f>0&&setTimeout(()=>b.remove(),f)}function timeAgo(c){const a=Date.now()-new Date(c).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(c,a=null){try{const f=localStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSet(c,a){try{localStorage.setItem(c,a)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,a=null){try{const f=sessionStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSessionSet(c,a){try{sessionStorage.setItem(c,a)}catch{}}function safeGetJSON(c,a=null){try{const f=localStorage.getItem(c);return f?JSON.parse(f):a}catch{return a}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,a){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(c,a){var f;((f=this._handlers)[c]||(f[c]=[])).push(a)},off(c,a){this._handlers[c]=this._handlers[c]?.filter(f=>f!==a)},emit(c,a){this._handlers[c]?.forEach(f=>f(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(a=>a.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const a=this._apps.findIndex(f=>f.id===c);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(c,a){const f=this._apps.find(h=>h.id===c);if(f){for(const[h,g]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(f[h]=g);DC_BUS.emit("apps:changed",this._apps)}return f}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function a(h){const g=document.getElementById("cards");if(!(!g||g.querySelector(".card"))){h=h||6;for(let m=0;m<h;m++){const b=c();g.appendChild(b),setTimeout(function(){b.classList.add("loaded")},m*60)}}}function f(){const h=document.getElementById("cards");if(h){var g=h.querySelectorAll(".skeleton-card");g.length&&(g.forEach(function(m,b){setTimeout(function(){m.style.opacity="0",m.style.transform="translateY(-10px)"},b*25)}),setTimeout(function(){g.forEach(function(m){m.parentNode&&m.remove()})},g.length*25+300))}}window.SkeletonLoader={show:a,hide:f}})(),(function(){var c="theme",a="user-themes",f="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],g=h.slice(),m=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],b=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],r=["fg-muted","hover","card-hover","base","success","error","warning"],v={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0",
Line truncated
|
||||
background:
|
||||
radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%),
|
||||
@@ -35,7 +35,7 @@
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`}function c(C,B){var $=B.lightBg||B.bg&&s(B.bg)>.4;return $?":root."+C+` button:hover {
|
||||
`}function l(C,B){var $=B.lightBg||B.bg&&s(B.bg)>.4;return $?":root."+C+` button:hover {
|
||||
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
|
||||
border-color: rgba(0, 0, 0, .15);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
|
||||
@@ -44,12 +44,12 @@
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function i(){m.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function y(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var k=safeGetJSON(o,{}),x=$,I=2;k[$]&&$!==B;)$=x+"-"+I++;return $}function u(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=h.length,Object.keys(v).forEach(function(E){h.indexOf(E)===-1&&delete v[E]});var $=C||safeGetJSON(o,{}),k=Object.keys($);if(k=k.filter(function(E){return h.indexOf(E)===-1}),!!k.length){var x="";k.forEach(function(E){var S=$[E];f.indexOf(E)===-1&&f.push(E);var P={};m.forEach(function(O){S[O]&&(P[O]=S[O])}),P["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(P.lightBg=!0);var D=e(P);r.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),v[E]=P,x+=":root."+E+` {
|
||||
`,m.forEach(function(O){P[O]&&(x+=" --"+O+": "+P[O]+`;
|
||||
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function i(){m.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function y(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var k=safeGetJSON(a,{}),x=$,I=2;k[$]&&$!==B;)$=x+"-"+I++;return $}function u(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),g.length=h.length,Object.keys(v).forEach(function(E){h.indexOf(E)===-1&&delete v[E]});var $=C||safeGetJSON(a,{}),k=Object.keys($);if(k=k.filter(function(E){return h.indexOf(E)===-1}),!!k.length){var x="";k.forEach(function(E){var S=$[E];g.indexOf(E)===-1&&g.push(E);var A={};m.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);r.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),v[E]=A,x+=":root."+E+` {
|
||||
`,m.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
|
||||
`)}),x+=`}
|
||||
`,x+=n(E,P),x+=c(E,P)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(o,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(o,JSON.stringify(B)),u(B);var k=safeGet(l);k&&f.indexOf(k)!==-1&&L(k)}}}).catch(function(){})}function T(){var C=safeGetJSON(g);if(C){var B=C.name||"Custom",$=y(B),k={name:B};m.forEach(function(E){C[E]&&(k[E]=C[E])});var x=safeGetJSON(o,{});x[$]=k,safeSet(o,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};m.forEach(function(E){k[E]&&(I[E]=k[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),i(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(l,C);var B=v[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var k=B&&B.lightBg;!k&&B&&B.bg&&(k=s(B.bg)>.4),k?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),u();var A=safeGet(l);A==="red"&&(A="black",safeSet(l,"black")),A&&A!=="dark"&&f.indexOf(A)===-1&&(A=null),L(A||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(l)||L(C.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=h,window.THEME_COLORS=v,window.THEME_PROPS=m,window.BASE_PROPS=b,window.DERIVED_PROPS=r,window.USER_THEMES_KEY=o,window.applyTheme=L,window.clearCustomProperties=i,window.injectUserThemeStyles=u,window.syncThemesFromServer=w,window.slugifyThemeName=y,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=d,window.rgbToHex=a,window.blendColors=p})(),(function(){let l=null;async function o(){if(l)return l;try{const d=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!d.ok)throw new Error(`methods HTTP ${d.status}`);const a=await d.json();return l=Array.isArray(a.providers)?a.providers:[],l}catch(d){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",d),[]}}function g(d){const a=document.getElementById("totp-overlay");if(!a)return;const p=a.querySelector(".totp-card");if(!p)return;const s=p.innerHTML;p.dataset.originalBody||(p.dataset.originalBody=s);const e=d.map(n=>{const c=n.config&&(n.config.label||n.name)||n.name;return`<button class="provider-btn" data-provider="${n.name}"
|
||||
`,x+=n(E,A),x+=l(E,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(a,JSON.stringify(B)),u(B);var k=safeGet(c);k&&g.indexOf(k)!==-1&&L(k)}}}).catch(function(){})}function T(){var C=safeGetJSON(f);if(C){var B=C.name||"Custom",$=y(B),k={name:B};m.forEach(function(E){C[E]&&(k[E]=C[E])});var x=safeGetJSON(a,{});x[$]=k,safeSet(a,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(f);var I={};m.forEach(function(E){k[E]&&(I[E]=k[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),g.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),i(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=v[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var k=B&&B.lightBg;!k&&B&&B.bg&&(k=s(B.bg)>.4),k?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),u();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&g.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||L(C.matches?"dark":"light")}),window.THEMES=g,window.BUILTIN_THEMES=h,window.THEME_COLORS=v,window.THEME_PROPS=m,window.BASE_PROPS=b,window.DERIVED_PROPS=r,window.USER_THEMES_KEY=a,window.applyTheme=L,window.clearCustomProperties=i,window.injectUserThemeStyles=u,window.syncThemesFromServer=w,window.slugifyThemeName=y,window.getActiveTheme=function(){return safeGet(c)||t()},window.deriveExtendedColors=e,window.hexToRgb=d,window.rgbToHex=o,window.blendColors=p})(),(function(){let c=null;async function a(){if(c)return c;try{const d=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!d.ok)throw new Error(`methods HTTP ${d.status}`);const o=await d.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(d){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",d),[]}}function f(d){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(!p)return;const s=p.innerHTML;p.dataset.originalBody||(p.dataset.originalBody=s);const e=d.map(n=>{const l=n.config&&(n.config.label||n.name)||n.name;return`<button class="provider-btn" data-provider="${n.name}"
|
||||
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
|
||||
Sign in with ${c}
|
||||
Sign in with ${l}
|
||||
</button>`}).join(`
|
||||
`);p.innerHTML=`
|
||||
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
@@ -57,7 +57,7 @@
|
||||
<p class="subtitle">Choose how to sign in</p>
|
||||
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${e}</div>
|
||||
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
|
||||
`,a.classList.add("show"),p.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const c=n.dataset.provider,t=d.find(i=>i.name===c);f(t)})})}function h(){const d=document.getElementById("totp-overlay");if(!d)return;const a=d.querySelector(".totp-card");!a||!a.dataset.originalBody||(a.innerHTML=a.dataset.originalBody,window.location.reload())}function f(d){const a=document.getElementById("totp-overlay");if(!a)return;const p=a.querySelector(".totp-card");if(p){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){p.innerHTML=`
|
||||
`,o.classList.add("show"),p.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const l=n.dataset.provider,t=d.find(i=>i.name===l);g(t)})})}function h(){const d=document.getElementById("totp-overlay");if(!d)return;const o=d.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function g(d){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(p){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){p.innerHTML=`
|
||||
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<p class="subtitle">Sign in with email</p>
|
||||
@@ -75,13 +75,13 @@
|
||||
<div style="margin-top: 16px;">
|
||||
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
|
||||
</div>
|
||||
`,a.classList.add("show");const s=p.querySelector("#auth-gate-email-input"),e=p.querySelector("#auth-gate-email-submit"),n=p.querySelector("#auth-gate-email-status"),c=p.querySelector("#auth-gate-back");e.addEventListener("click",async()=>{const t=(s.value||"").trim();if(!t||!t.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}e.disabled=!0,n.textContent="Sending\u2026",n.style.color="";try{const y=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:t})})).json();if(y.success){const u=y.deliveredVia||"email",w=y.maskedEmail||t;u==="dev-console"?n.innerHTML=`
|
||||
`,o.classList.add("show");const s=p.querySelector("#auth-gate-email-input"),e=p.querySelector("#auth-gate-email-submit"),n=p.querySelector("#auth-gate-email-status"),l=p.querySelector("#auth-gate-back");e.addEventListener("click",async()=>{const t=(s.value||"").trim();if(!t||!t.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}e.disabled=!0,n.textContent="Sending\u2026",n.style.color="";try{const y=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:t})})).json();if(y.success){const u=y.deliveredVia||"email",w=y.maskedEmail||t;u==="dev-console"?n.innerHTML=`
|
||||
Check the server logs for your one-time link.
|
||||
(Dev mode: no SMTP configured. In production this would email <strong>${w}</strong>.)`:n.innerHTML=`Sign-in link sent to <strong>${w}</strong>.
|
||||
Check your inbox (and spam folder).`,n.style.color="var(--success, #2a7)"}else n.textContent=y.error||"Could not send link.",n.style.color="var(--error, #d33)",e.disabled=!1}catch{n.textContent="Connection error. Try again.",n.style.color="var(--error, #d33)",e.disabled=!1}}),s.addEventListener("keydown",t=>{t.key==="Enter"&&e.click()}),c.addEventListener("click",t=>{t.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function m(){if(!document.getElementById("totp-overlay"))return;const a=await o();if(a.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(a.length===1&&a[0].name==="totp"){b(a[0]);return}g(a)}function b(d){const a=l&&l.find(n=>n.name==="email");if(!a){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const p=document.getElementById("totp-overlay"),s=p.querySelector(".totp-card");if(!s)return;if(s.dataset.originalBody||(s.dataset.originalBody=s.innerHTML),!s.querySelector("#auth-gate-email-alt")){const n=document.createElement("div");n.id="auth-gate-email-alt",n.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",n.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
|
||||
Check your inbox (and spam folder).`,n.style.color="var(--success, #2a7)"}else n.textContent=y.error||"Could not send link.",n.style.color="var(--error, #d33)",e.disabled=!1}catch{n.textContent="Connection error. Try again.",n.style.color="var(--error, #d33)",e.disabled=!1}}),s.addEventListener("keydown",t=>{t.key==="Enter"&&e.click()}),l.addEventListener("click",t=>{t.preventDefault(),f(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function m(){if(!document.getElementById("totp-overlay"))return;const o=await a();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){b(o[0]);return}f(o)}function b(d){const o=c&&c.find(n=>n.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const p=document.getElementById("totp-overlay"),s=p.querySelector(".totp-card");if(!s)return;if(s.dataset.originalBody||(s.dataset.originalBody=s.innerHTML),!s.querySelector("#auth-gate-email-alt")){const n=document.createElement("div");n.id="auth-gate-email-alt",n.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",n.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
|
||||
style="color: var(--accent); text-decoration: none;">
|
||||
Or sign in with email instead \u2192
|
||||
</a>`,s.appendChild(n),n.querySelector("#auth-gate-email-alt-link").addEventListener("click",c=>{c.preventDefault(),f(a)})}p.classList.add("show")}window.__dc_049_handled=!0;function r(d){try{const a=new URL(d,window.location.origin);if(!["http:","https:"].includes(a.protocol))return!1;if(a.origin===window.location.origin)return!0;if(a.protocol!=="https:")return!1;const p=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return a.hostname===p.slice(1)||a.hostname.endsWith(p)}catch{return!1}}const v=new URLSearchParams(window.location.search);if(v.get("auth")==="required"){const d=v.get("return");if(d&&r(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(m,0)}window._showAuthGate=m,window._authGateMethods=o})(),(function(){function l(){const r=document.querySelector(".totp-card");if(!r)return;const d=getComputedStyle(r).backgroundColor.match(/\d+/g);if(!d)return;const a=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,p=r.querySelector(".totp-logo-dark"),s=r.querySelector(".totp-logo-light");p&&(p.style.display=a>.5?"none":""),s&&(s.style.display=a>.5?"":"none")}function o(){const r=document.getElementById("totp-overlay");if(r){r.classList.add("show"),setTimeout(l,50);const v=r.querySelector(".totp-digits input");v&&setTimeout(()=>v.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const r=document.getElementById("totp-overlay");r&&r.classList.remove("show")}function h(r,v){const d=new URL(r,window.location.origin);if(d.origin===window.location.origin)return d.toString();const a=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,p=d.hostname===a.slice(1)||d.hostname.endsWith(a);if(d.protocol!=="https:"||!p)return null;if(!v)return d.toString();const s=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",v),d.searchParams.set("return",s),d.toString()}const f=document.getElementById("totp-digits");if(f){const r=f.querySelectorAll("input");r.forEach((v,d)=>{v.addEventListener("input",a=>{const p=a.target.value.replace(/\D/g,"");a.target.value=p.slice(0,1),p&&d<r.length-1&&r[d+1].focus();const s=Array.from(r).map(e=>e.value).join("");s.length===6&&m(s)}),v.addEventListener("keydown",a=>{a.key==="Backspace"&&!a.target.value&&d>0&&(r[d-1].focus(),r[d-1].value="")}),v.addEventListener("paste",a=>{a.preventDefault();const p=(a.clipboardData.getData("text")||"").replace(/\D/g,"");p.length>=6&&(r.forEach((s,e)=>{s.value=p[e]||""}),r[5].focus(),m(p.slice(0,6)))})})}async function m(r){const v=document.getElementById("totp-error");v.textContent="Verifying...",v.className="totp-error verifying";try{const a=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:r})})).json();if(a.success){v.textContent="",a.csrfToken&&(csrfToken=a.csrfToken),g();const p=safeSessionGet("totp_redirect");if(p){try{sessionStorage.removeItem("totp_redirect")}catch{}const s=h(p,a.ssoToken);if(!s)return;window.location.href=s;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{v.textContent=a.error||"Invalid code",v.className="totp-error";const p=document.querySelectorAll("#totp-digits input");p.forEach(s=>{s.value=""}),p[0]?.focus()}}catch{v.textContent="Connection error",v.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const r=urlParams.get("return");if(r)try{const v=new URL(r,window.location.origin),d=v.hostname,a=v.origin===window.location.origin,p=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,s=d.endsWith(p)||d===p.substring(1);(a||s)&&safeSessionSet("totp_redirect",r)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=o})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function o(b){const r=document.getElementById("totp-recovery-link");r&&(r.style.display=b?"":"none")}function g(){const b=document.getElementById("totp-recovery-panel");b&&(b.style.display="");const r=document.getElementById("totp-recovery-status"),v=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");v&&(v.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(a=>{r.textContent=a.hint||"",a.status==="healthy"?r.style.borderColor="var(--ok-fg, #7ef2ff)":a.status==="unreadable"?(r.style.borderColor="var(--bad-fg, #ff9aa3)",r.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):a.status==="not_configured"?r.style.borderColor="var(--muted)"Line truncated
|
||||
</a>`,s.appendChild(n),n.querySelector("#auth-gate-email-alt-link").addEventListener("click",l=>{l.preventDefault(),g(o)})}p.classList.add("show")}window.__dc_049_handled=!0;function r(d){try{const o=new URL(d,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const p=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===p.slice(1)||o.hostname.endsWith(p)}catch{return!1}}const v=new URLSearchParams(window.location.search);if(v.get("auth")==="required"){const d=v.get("return");if(d&&r(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(m,0)}window._showAuthGate=m,window._authGateMethods=a})(),(function(){function c(){const r=document.querySelector(".totp-card");if(!r)return;const d=getComputedStyle(r).backgroundColor.match(/\d+/g);if(!d)return;const o=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,p=r.querySelector(".totp-logo-dark"),s=r.querySelector(".totp-logo-light");p&&(p.style.display=o>.5?"none":""),s&&(s.style.display=o>.5?"":"none")}function a(){const r=document.getElementById("totp-overlay");if(r){r.classList.add("show"),setTimeout(c,50);const v=r.querySelector(".totp-digits input");v&&setTimeout(()=>v.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function f(){const r=document.getElementById("totp-overlay");r&&r.classList.remove("show")}function h(r,v){const d=new URL(r,window.location.origin);if(d.origin===window.location.origin)return d.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,p=d.hostname===o.slice(1)||d.hostname.endsWith(o);if(d.protocol!=="https:"||!p)return null;if(!v)return d.toString();const s=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",v),d.searchParams.set("return",s),d.toString()}const g=document.getElementById("totp-digits");if(g){const r=g.querySelectorAll("input");r.forEach((v,d)=>{v.addEventListener("input",o=>{const p=o.target.value.replace(/\D/g,"");o.target.value=p.slice(0,1),p&&d<r.length-1&&r[d+1].focus();const s=Array.from(r).map(e=>e.value).join("");s.length===6&&m(s)}),v.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&d>0&&(r[d-1].focus(),r[d-1].value="")}),v.addEventListener("paste",o=>{o.preventDefault();const p=(o.clipboardData.getData("text")||"").replace(/\D/g,"");p.length>=6&&(r.forEach((s,e)=>{s.value=p[e]||""}),r[5].focus(),m(p.slice(0,6)))})})}async function m(r){const v=document.getElementById("totp-error");v.textContent="Verifying...",v.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:r})})).json();if(o.success){v.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),f();const p=safeSessionGet("totp_redirect");if(p){try{sessionStorage.removeItem("totp_redirect")}catch{}const s=h(p,o.ssoToken);if(!s)return;window.location.href=s;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{v.textContent=o.error||"Invalid code",v.className="totp-error";const p=document.querySelectorAll("#totp-digits input");p.forEach(s=>{s.value=""}),p[0]?.focus()}}catch{v.textContent="Connection error",v.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const r=urlParams.get("return");if(r)try{const v=new URL(r,window.location.origin),d=v.hostname,o=v.origin===window.location.origin,p=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,s=d.endsWith(p)||d===p.substring(1);(o||s)&&safeSessionSet("totp_redirect",r)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=a})(),(function(){"use strict";async function c(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function a(b){const r=document.getElementById("totp-recovery-link");r&&(r.style.display=b?"":"none")}function f(){const b=document.getElementById("totp-recovery-panel");b&&(b.style.display="");const r=document.getElementById("totp-recovery-status"),v=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");v&&(v.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",c().then(o=>{r.textContent=o.hint||"",o.status==="healthy"?r.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(r.style.borderColor="var(--bad-fg, #ff9aa3)",r.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?r.style.borderColor="var(--muted)"Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
||||
<h3>\u{1F4C2} Browse for Media Folders</h3>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const o=document.getElementById("service-creds-modal");let g=null;const h=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function m(a){return a.externalUrl||a.url||""}function b(a){const p=document.getElementById("svc-creds-error");p.textContent=a,p.style.display=""}function r(){const a=document.getElementById("svc-creds-error");a.textContent="",a.style.display="none"}window.openServiceCredsModal=async function(a){g=a,r();const p=document.getElementById("svc-creds-title"),s=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),n=document.getElementById("svc-creds-apikey"),c=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");p.textContent=a.name+" Credentials";const i=!!a.isExternal,y=h.includes(a.id)||h.includes(a.appTemplate),u=f.includes(a.id)||f.includes(a.appTemplate);e.style.display=i?"":"none",n.style.display=y?"":"none",t.style.display=u?"":"none",c.style.display=i?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",i?(s.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${a.name}`):y?s.textContent="API key bypasses the app login screen automatically.":s.textContent="Credentials are injected automatically when accessing this service.",await v(a),o.classList.add("show")};async function v(a){const p=document.getElementById("svc-creds-dot"),s=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let n=!1;if(a.isExternal){try{const i=await(await fetch(`/api/v1/seedhost-creds?serviceId=${a.id}`)).json();i.success?(document.getElementById("svc-seedhost-user").value=i.username||"",i.hasCredentials&&(n=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const i=await(await fetch(`/api/v1/services/${a.id}/credentials`)).json();i.success&&(i.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",n=!0):document.getElementById("svc-apikey-input").value="",i.hasBasicAuth&&!a.isExternal?(document.getElementById("svc-basic-user").value=i.username||"",n=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const c=a.id||a.appTemplate;if(f.includes(c)&&await d(a),n){p.style.background="var(--ok-fg, #74dfc4)",s.style.color="var(--ok-fg, #74dfc4)",s.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${a.id}`);t&&t.classList.add("has-creds")}else p.style.background="var(--muted)",s.style.color="var(--muted)",s.textContent="No credentials stored",e.style.display="none"}async function d(a){const p=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status"),e=a.id||a.appTemplate,n=m(a);if(!n){p.innerHTML='<option value="">-- No service URL --</option>';return}p.innerHTML='<option value="">Loading...</option>',s.textContent="";try{const c=new URLSearchParams({service:e,url:n}),i=await(await fetch(`/api/v1/arr/quality-profiles?${c}`)).json();if(!i.success||!i.profiles?.length){p.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}p.innerHTML="";for(const y of i.profiles){const u=document.createElement("option");u.value=y.id,u.textContent=y.name,p.appendChild(u)}if(i.storedProfileId&&(p.value=String(i.storedProfileId)),!p.value){const y=i.profiles.find(u=>/720/i.test(u.name));y&&(p.value=String(y.id))}!p.value&&i.profiles.length&&(p.value=String(i.profiles[0].id)),s.innerHTML=`<span style="color: var(--ok-fg);">${i.profiles.length} profiles loaded</span>`}catch(c){p.innerHTML='<option value="">-- Failed to load --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${c.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const a=g.id||g.appTemplate,p=m(g),e=document.getElementById("svc-apikey-input")?.value.trim(),n=document.getElementById("svc-quality-select"),c=document.getElementById("svc-quality-status");if(!p){c.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){c.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}n.innerHTML='<option value="">Fetching...</option>',c.textContent="";try{const t=new URLSearchParams({service:a,url:p,apiKey:e}),y=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!y.success){n.innerHTML='<option value="">-- Error --</option>',c.innerHTML=`<span style="color: var(--error, #c62828);">${y.error||"Failed to feLine truncated
|
||||
</div>`);const a=document.getElementById("service-creds-modal");let f=null;const h=["sonarr","radarr","prowlarr","overseerr"],g=["sonarr","radarr"];function m(o){return o.externalUrl||o.url||""}function b(o){const p=document.getElementById("svc-creds-error");p.textContent=o,p.style.display=""}function r(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){f=o,r();const p=document.getElementById("svc-creds-title"),s=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),n=document.getElementById("svc-creds-apikey"),l=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");p.textContent=o.name+" Credentials";const i=!!o.isExternal,y=h.includes(o.id)||h.includes(o.appTemplate),u=g.includes(o.id)||g.includes(o.appTemplate);e.style.display=i?"":"none",n.style.display=y?"":"none",t.style.display=u?"":"none",l.style.display=i?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",i?(s.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):y?s.textContent="API key bypasses the app login screen automatically.":s.textContent="Credentials are injected automatically when accessing this service.",await v(o),a.classList.add("show")};async function v(o){const p=document.getElementById("svc-creds-dot"),s=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let n=!1;if(o.isExternal){try{const i=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();i.success?(document.getElementById("svc-seedhost-user").value=i.username||"",i.hasCredentials&&(n=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const i=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();i.success&&(i.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",n=!0):document.getElementById("svc-apikey-input").value="",i.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=i.username||"",n=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const l=o.id||o.appTemplate;if(g.includes(l)&&await d(o),n){p.style.background="var(--ok-fg, #74dfc4)",s.style.color="var(--ok-fg, #74dfc4)",s.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${o.id}`);t&&t.classList.add("has-creds")}else p.style.background="var(--muted)",s.style.color="var(--muted)",s.textContent="No credentials stored",e.style.display="none"}async function d(o){const p=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status"),e=o.id||o.appTemplate,n=m(o);if(!n){p.innerHTML='<option value="">-- No service URL --</option>';return}p.innerHTML='<option value="">Loading...</option>',s.textContent="";try{const l=new URLSearchParams({service:e,url:n}),i=await(await fetch(`/api/v1/arr/quality-profiles?${l}`)).json();if(!i.success||!i.profiles?.length){p.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}p.innerHTML="";for(const y of i.profiles){const u=document.createElement("option");u.value=y.id,u.textContent=y.name,p.appendChild(u)}if(i.storedProfileId&&(p.value=String(i.storedProfileId)),!p.value){const y=i.profiles.find(u=>/720/i.test(u.name));y&&(p.value=String(y.id))}!p.value&&i.profiles.length&&(p.value=String(i.profiles[0].id)),s.innerHTML=`<span style="color: var(--ok-fg);">${i.profiles.length} profiles loaded</span>`}catch(l){p.innerHTML='<option value="">-- Failed to load --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${l.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!f)return;const o=f.id||f.appTemplate,p=m(f),e=document.getElementById("svc-apikey-input")?.value.trim(),n=document.getElementById("svc-quality-select"),l=document.getElementById("svc-quality-status");if(!p){l.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){l.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}n.innerHTML='<option value="">Fetching...</option>',l.textContent="";try{const t=new URLSearchParams({service:o,url:p,apiKey:e}),y=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!y.success){n.innerHTML='<option value="">-- Error --</option>',l.innerHTML=`<span style="color: var(--error, #c62828);">${y.error||"Failed to feLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);async function o(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:b,sessionDuration:r,isSetUp:v}=m.config,d=document.getElementById("totp-status-dot"),a=document.getElementById("totp-status-text"),p=document.getElementById("totp-status-banner"),s=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),c=document.getElementById("totp-disable-section");if(b&&v){d.style.background="var(--ok-fg, #7ef2ff)",p.style.borderColor="var(--ok-fg, #7ef2ff)",p.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",a.textContent="TOTP is active",a.style.color="var(--ok-fg, #7ef2ff)",s.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",c.style.display="block",document.getElementById("totp-duration-select").value=r}else d.style.background="var(--muted)",p.style.borderColor="var(--border)",p.style.background="transparent",a.textContent="TOTP is not configured",a.style.color="var(--muted)",s.style.display="block",e.style.display="none",n.style.display="none",c.style.display="none";h(b&&v,r)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(f,m){const b=document.getElementById("auth-card"),r=document.getElementById("auth-pill"),v=document.getElementById("auth-dot"),d=document.getElementById("auth-status-text");b&&(f?(b.setAttribute("data-status","on"),r.className="badge on",r.textContent="YES",v.className="dot ok at-bl",d.textContent="Session: "+(g[m]||m)):(b.setAttribute("data-status","off"),r.className="badge off",r.textContent="NO",v.className="dot bad at-bl",d.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!f){m.textContent="Paste a Base32 secret key first";return}try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();r.success?(m.textContent="",document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):m.textContent=r.error||r.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const m={service:"DashCaddy",type:"totp-secret",secret:f,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},b=new Blob([JSON.stringify(m,null,2)],{type:"application/json"}),r=URL.createObjectURL(b),v=document.createElement("a");v.href=r,v.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(v),v.click(),document.body.removeChild(v),URL.revokeObjectURL(r);const d=document.getElementById("totp-download-backup");d.textContent="\u2705 Saved",setTimeout(()=>{d.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=doLine truncated
|
||||
</div>`);async function a(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:b,sessionDuration:r,isSetUp:v}=m.config,d=document.getElementById("totp-status-dot"),o=document.getElementById("totp-status-text"),p=document.getElementById("totp-status-banner"),s=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),l=document.getElementById("totp-disable-section");if(b&&v){d.style.background="var(--ok-fg, #7ef2ff)",p.style.borderColor="var(--ok-fg, #7ef2ff)",p.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",o.textContent="TOTP is active",o.style.color="var(--ok-fg, #7ef2ff)",s.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",l.style.display="block",document.getElementById("totp-duration-select").value=r}else d.style.background="var(--muted)",p.style.borderColor="var(--border)",p.style.background="transparent",o.textContent="TOTP is not configured",o.style.color="var(--muted)",s.style.display="block",e.style.display="none",n.style.display="none",l.style.display="none";h(b&&v,r)}catch(g){console.warn("Failed to load TOTP settings:",g)}}const f={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(g,m){const b=document.getElementById("auth-card"),r=document.getElementById("auth-pill"),v=document.getElementById("auth-dot"),d=document.getElementById("auth-status-text");b&&(g?(b.setAttribute("data-status","on"),r.className="badge on",r.textContent="YES",v.className="dot ok at-bl",d.textContent="Session: "+(f[m]||m)):(b.setAttribute("data-status","off"),r.className="badge off",r.textContent="NO",v.className="dot bad at-bl",d.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(g){c.logError("[TOTP] Setup Failed",g,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const g=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!g){m.textContent="Paste a Base32 secret key first";return}try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:g})})).json();r.success?(m.textContent="",document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):m.textContent=r.error||r.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(g).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent.trim();if(!g)return;const m={service:"DashCaddy",type:"totp-secret",secret:g,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},b=new Blob([JSON.stringify(m,null,2)],{type:"application/json"}),r=URL.createObjectURL(b),v=document.createElement("a");v.href=r,v.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(v),v.click(),document.body.removeChild(v),URL.revokeObjectURL(r);const d=document.getElementById("totp-download-backup");d.textContent="\u2705 Saved",setTimeout(()=>{d.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const g=doLine truncated
|
||||
<div id="token-management-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3>\u{1F511} DNS Credentials</h3>
|
||||
@@ -306,9 +306,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);function l(){return Object.keys(SITE.dnsServers||{})}function o(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const i=l();if(i.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const y of i)t.insertAdjacentHTML("beforeend",`
|
||||
`);function c(){return Object.keys(SITE.dnsServers||{})}function a(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function f(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const i=c();if(i.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const y of i)t.insertAdjacentHTML("beforeend",`
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">${o(y)}</h4>
|
||||
<h4 class="token-section-title">${a(y)}</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="${y}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
@@ -329,7 +329,7 @@
|
||||
</div>
|
||||
<div class="token-status" id="${y}-token-status"></div>
|
||||
</div>
|
||||
`)}function h(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const y=new Uint8Array(32);return crypto.getRandomValues(y),t=Array.from(y,u=>u.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=h();function m(t,i){if(!t)return"";const y=crypto.getRandomValues(new Uint8Array(8)),u=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(i+u);let T="";for(let L=0;L<t.length;L++){const A=t.charCodeAt(L)^w[L%w.length]^y[L%y.length]^L*31+17&255;T+=String.fromCharCode(A)}return u+":"+btoa(T)}function b(t,i){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),A=atob(t.substring(17)),C=new TextEncoder().encode(i+T);let B="";for(let $=0;$<A.length;$++){const k=A.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(k)}return B}const u=atob(t);let w="";for(let T=0;T<u.length;T++){const L=u.charCodeAt(T)^i.charCodeAt(T%i.length);w+=String.fromCharCode(L)}return w}catch{return""}}function r(t,i,y){const u=safeGet(`${t}-${i}-${y}-enc`);return b(u,f)}function v(t,i,y,u){const w=`${t}-${i}-${y}-enc`;u?safeSet(w,m(u,f)):safeRemove(w)}function d(t,i){return r(t,i,"token")}function a(t,i){return r(t,i,"username")}function p(t,i,y){v(t,i,"token",y)}function s(t,i,y){v(t,i,"username",y)}function e(){const t={};for(const i of l())t[i]={readonly:{username:a(i,"readonly"),token:d(i,"readonly")},admin:{username:a(i,"admin"),token:d(i,"admin")}};return t}function n(){l().forEach(t=>{["readonly","admin"].forEach(i=>{["token","username"].forEach(y=>{safeRemove(`${t}-${i}-${y}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function c(t){const i=d(t,"readonly"),y=a(t,"readonly"),u=d(t,"admin"),w=a(t,"admin"),T=b(safeGet(`${t}-token-enc`),f),L=b(safeGet(`${t}-username-enc`),f);return{username:w||y||L,token:u||i||T,readonlyToken:i||T,readonlyUsername:y||L,adminToken:u||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),i=e();l().forEach(y=>{const u=i[y];document.getElementById(`${y}-readonly-username`).value=u.readonly.username,document.getElementById(`${y}-readonly-token`).value=u.readonly.token,document.getElementById(`${y}-admin-username`).value=u.admin.username,document.getElementById(`${y}-admin-token`).value=u.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const i=t.target.closest(".token-toggle");if(i){const y=i.dataset.target,u=document.getElementById(y);u.type==="password"?(u.type="text",i.textContent="\u{1F648}"):(u.type="password",i.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(u=>{s(u,"readonly",document.getElementById(`${u}-readonly-username`).value.trim()),p(u,"readonly",document.getElementById(`${u}-readonly-token`).value.trim()),s(u,"admin",document.getElementById(`${u}-admin-username`).value.trim()),p(u,"admin",document.getElementById(`${u}-admin-token`).value.trim())});const i={};let y=!1;if(t.forEach(u=>{const w={},T=document.getElementById(`${u}-readonly-username`).value.trim(),L=document.getElementById(`${u}-readonly-token`).value.trim(),A=document.getElementById(`${u}-admin-username`).value.trim(),C=document.getElementById(`${u}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},y=!0),A&&C&&(w.admin={username:A,password:C},y=!0),Object.keys(w).length>0&&(i[u]=w)}),y){t.forEach(u=>{i[u]&&(document.getElementById(`${u}-token-status`).textContent="Verifying...",document.getElementById(`${u}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:i})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!i[T]){L.textContent="";return}const A=w.results[T];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(u){console.erro
Line truncated
|
||||
`)}function h(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const y=new Uint8Array(32);return crypto.getRandomValues(y),t=Array.from(y,u=>u.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const g=h();function m(t,i){if(!t)return"";const y=crypto.getRandomValues(new Uint8Array(8)),u=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(i+u);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^w[L%w.length]^y[L%y.length]^L*31+17&255;T+=String.fromCharCode(P)}return u+":"+btoa(T)}function b(t,i){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),C=new TextEncoder().encode(i+T);let B="";for(let $=0;$<P.length;$++){const k=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(k)}return B}const u=atob(t);let w="";for(let T=0;T<u.length;T++){const L=u.charCodeAt(T)^i.charCodeAt(T%i.length);w+=String.fromCharCode(L)}return w}catch{return""}}function r(t,i,y){const u=safeGet(`${t}-${i}-${y}-enc`);return b(u,g)}function v(t,i,y,u){const w=`${t}-${i}-${y}-enc`;u?safeSet(w,m(u,g)):safeRemove(w)}function d(t,i){return r(t,i,"token")}function o(t,i){return r(t,i,"username")}function p(t,i,y){v(t,i,"token",y)}function s(t,i,y){v(t,i,"username",y)}function e(){const t={};for(const i of c())t[i]={readonly:{username:o(i,"readonly"),token:d(i,"readonly")},admin:{username:o(i,"admin"),token:d(i,"admin")}};return t}function n(){c().forEach(t=>{["readonly","admin"].forEach(i=>{["token","username"].forEach(y=>{safeRemove(`${t}-${i}-${y}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function l(t){const i=d(t,"readonly"),y=o(t,"readonly"),u=d(t,"admin"),w=o(t,"admin"),T=b(safeGet(`${t}-token-enc`),g),L=b(safeGet(`${t}-username-enc`),g);return{username:w||y||L,token:u||i||T,readonlyToken:i||T,readonlyUsername:y||L,adminToken:u||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{f();const t=document.getElementById("token-management-modal"),i=e();c().forEach(y=>{const u=i[y];document.getElementById(`${y}-readonly-username`).value=u.readonly.username,document.getElementById(`${y}-readonly-token`).value=u.readonly.token,document.getElementById(`${y}-admin-username`).value=u.admin.username,document.getElementById(`${y}-admin-token`).value=u.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const i=t.target.closest(".token-toggle");if(i){const y=i.dataset.target,u=document.getElementById(y);u.type==="password"?(u.type="text",i.textContent="\u{1F648}"):(u.type="password",i.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=c();t.forEach(u=>{s(u,"readonly",document.getElementById(`${u}-readonly-username`).value.trim()),p(u,"readonly",document.getElementById(`${u}-readonly-token`).value.trim()),s(u,"admin",document.getElementById(`${u}-admin-username`).value.trim()),p(u,"admin",document.getElementById(`${u}-admin-token`).value.trim())});const i={};let y=!1;if(t.forEach(u=>{const w={},T=document.getElementById(`${u}-readonly-username`).value.trim(),L=document.getElementById(`${u}-readonly-token`).value.trim(),P=document.getElementById(`${u}-admin-username`).value.trim(),C=document.getElementById(`${u}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},y=!0),P&&C&&(w.admin={username:P,password:C},y=!0),Object.keys(w).length>0&&(i[u]=w)}),y){t.forEach(u=>{i[u]&&(document.getElementById(`${u}-token-status`).textContent="Verifying...",document.getElementById(`${u}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:i})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!i[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(u){console.erro
Line truncated
|
||||
|
||||
Current: ${s.currentVersion}
|
||||
New: ${s.updateVersion}
|
||||
@@ -337,9 +337,9 @@ New: ${s.updateVersion}
|
||||
`+(s.updateTitle?`${s.updateTitle}
|
||||
|
||||
`:"")+`The DNS server will restart during the update.
|
||||
Proceed?`)){d.textContent=a,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const c=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(v)}`,{method:"POST"})).json();if(!c.success)throw new Error(c.error||"Update failed");if(c.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${c.newVersion}`;const t=c.downloadLink?`
|
||||
Download: ${c.downloadLink}`:"",i=c.instructionsLink?`
|
||||
Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} update requires manual installation. Current: ${c.previousVersion} \u2192 ${c.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${r.toUpperCase()} updated successfully! ${c.previousVersion} \u2192 ${c.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=a,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(p){console.error("DNS update error:",p),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${r.toUpperCase()}: ${p.message}`,"error"),setTimeout(()=>{d.textContent=a,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-update"]');if(!v)return;const d=v.id.replace("-update","");SITE.dnsServers[d]&&o(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`
|
||||
Proceed?`)){d.textContent=o,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const l=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(v)}`,{method:"POST"})).json();if(!l.success)throw new Error(l.error||"Update failed");if(l.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${l.newVersion}`;const t=l.downloadLink?`
|
||||
Download: ${l.downloadLink}`:"",i=l.instructionsLink?`
|
||||
Instructions: ${l.instructionsLink}`:"";showNotification(`${r.toUpperCase()} update requires manual installation. Current: ${l.previousVersion} \u2192 ${l.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${r.toUpperCase()} updated successfully! ${l.previousVersion} \u2192 ${l.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(p){console.error("DNS update error:",p),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${r.toUpperCase()}: ${p.message}`,"error"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-update"]');if(!v)return;const d=v.id.replace("-update","");SITE.dnsServers[d]&&a(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`
|
||||
<div id="dns-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 id="dns-settings-title">DNS Settings</h3>
|
||||
@@ -366,7 +366,7 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
<button id="dns-settings-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let g=null;function h(r){g=r;const v=SITE.dnsServers[r]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(v.name||r).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=v.ip||"",document.getElementById("dns-edit-port").value=v.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=v.name||"",d.classList.add("show")}async function f(){if(!g)return;const r=document.getElementById("dns-edit-ip").value.trim(),v=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!r){showNotification("Server IP is required","warning");return}const a={dnsServers:{}};a.dnsServers[g]={ip:r,port:String(v)},d&&(a.dnsServers[g].name=d);try{const s=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();s.success?(SITE.dnsServers[g]=a.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(s.error||"Failed to save settings","error")}catch(p){showNotification("Failed to save: "+p.message,"error")}}async function m(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const v=await(await secureFetch("/api/v1/config")).json();v.dnsServers&&delete v.dnsServers[g];const a=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:v.dnsServers||{}})})).json();if(a.success){delete SITE.dnsServers[g];const p=document.querySelector(`.top [data-app="${g}"]`);p&&p.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(a.error||"Failed to remove","error")}catch(r){showNotification("Failed to remove: "+r.message,"error")}}function b(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",m),document.getElementById("dns-settings-modal")?.addEventListener("click",r=>{r.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-settings"]');if(!v)return;const d=v.id.replace("-settings","");SITE.dnsServers[d]&&(r.stopPropagation(),h(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
</div>`);let f=null;function h(r){f=r;const v=SITE.dnsServers[r]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(v.name||r).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=v.ip||"",document.getElementById("dns-edit-port").value=v.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=v.name||"",d.classList.add("show")}async function g(){if(!f)return;const r=document.getElementById("dns-edit-ip").value.trim(),v=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!r){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[f]={ip:r,port:String(v)},d&&(o.dnsServers[f].name=d);try{const s=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();s.success?(SITE.dnsServers[f]=o.dnsServers[f],showNotification(`${f.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(s.error||"Failed to save settings","error")}catch(p){showNotification("Failed to save: "+p.message,"error")}}async function m(){if(f&&confirm(`Remove ${f.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const v=await(await secureFetch("/api/v1/config")).json();v.dnsServers&&delete v.dnsServers[f];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:v.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[f];const p=document.querySelector(`.top [data-app="${f}"]`);p&&p.remove(),showNotification(`${f.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(o.error||"Failed to remove","error")}catch(r){showNotification("Failed to remove: "+r.message,"error")}}function b(){closeModal("dns-settings-modal"),f=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",g),document.getElementById("dns-settings-delete")?.addEventListener("click",m),document.getElementById("dns-settings-modal")?.addEventListener("click",r=>{r.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-settings"]');if(!v)return;const d=v.id.replace("-settings","");SITE.dnsServers[d]&&(r.stopPropagation(),h(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
<div id="logs-modal" class="logs-modal">
|
||||
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<div class="logs-header">
|
||||
@@ -390,13 +390,13 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let l=null,o=null,g=!1,h=null,f=null,m=!1,b=null,r=null,v=!1,d=null,a=!1;async function p(k,x=25){try{const I=getDnsServerAddr(k),E=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function s(k){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[k]||"var(--fg)"}function e(k){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",k.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(k.raw)}</span>`,x;const I=s(k.rcode),E=k.rcode==="Refused"||k.rcode==="REFUSED";return x.innerHTML=`
|
||||
</div>`);let c=null,a=null,f=!1,h=null,g=null,m=!1,b=null,r=null,v=!1,d=null,o=!1;async function p(k,x=25){try{const I=getDnsServerAddr(k),E=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function s(k){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[k]||"var(--fg)"}function e(k){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",k.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(k.raw)}</span>`,x;const I=s(k.rcode),E=k.rcode==="Refused"||k.rcode==="REFUSED";return x.innerHTML=`
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(k.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(k.client)}">${escapeHtml(k.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${E?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(k.domain)}">${escapeHtml(k.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(k.type)}</span>
|
||||
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(k.rcode)}</span>
|
||||
`,x}async function n(){if(v){await B();return}if(m){await T();return}if(g||!l)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(l,k);if(I.error){x.innerHTML=`
|
||||
`,x}async function n(){if(v){await B();return}if(m){await T();return}if(f||!c)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(c,k);if(I.error){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(I.error)}</div>
|
||||
@@ -413,13 +413,13 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function c(k){l=k,g=!1,m=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${k.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),n(),o=setInterval(n,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),o&&(clearInterval(o),o=null),y(),l=null,m=!1,h=null,f=null,v=!1,b=null,r=null,g=!1}function i(k){d&&y();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),E=document.getElementById("logs-content");o&&(clearInterval(o),o=null);try{d=new EventSource(`/api/v1/logs/stream/${k}`),a=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),d.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),y();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${M}</div>
|
||||
</div>`}}function l(k){c=k,f=!1,m=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${k.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),n(),a=setInterval(n,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),y(),c=null,m=!1,h=null,g=null,v=!1,b=null,r=null,f=!1}function i(k){d&&y();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),E=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{d=new EventSource(`/api/v1/logs/stream/${k}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),d.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),y();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${_}</div>
|
||||
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
|
||||
`,E.appendChild(O),E.scrollTop=E.scrollHeight;E.children.length>500;)E.removeChild(E.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},d.onerror=P=>{console.error("EventSource error:",P),y()}}catch(S){console.error("Failed to start streaming:",S),y()}}function y(){d&&(d.close(),d=null),a=!1;const k=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");k&&(k.classList.remove("active"),k.textContent="\u{1F4E1} Live",k.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),m&&h&&!o&&(o=setInterval(T,DC.POLL.LOGS))}async function u(k,x=100){try{const I=`/api/v1/logs/container/${k}?tail=${x}×tamps=true`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${E.status}: ${E.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=k.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
|
||||
`,E.appendChild(O),E.scrollTop=E.scrollHeight;E.children.length>500;)E.removeChild(E.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},d.onerror=A=>{console.error("EventSource error:",A),y()}}catch(S){console.error("Failed to start streaming:",S),y()}}function y(){d&&(d.close(),d=null),o=!1;const k=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");k&&(k.classList.remove("active"),k.textContent="\u{1F4E1} Live",k.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),m&&h&&!a&&(a=setInterval(T,DC.POLL.LOGS))}async function u(k,x=100){try{const I=`/api/v1/logs/container/${k}?tail=${x}×tamps=true`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${E.status}: ${E.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=k.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${E}</div>
|
||||
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(k.text)}</div>
|
||||
`,x}async function T(){if(g||!h||!m)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await u(h,k);if(I.error){x.innerHTML=`
|
||||
`,x}async function T(){if(f||!h||!m)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await u(h,k);if(I.error){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(I.error)}</div>
|
||||
@@ -433,10 +433,10 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function L(k,x){h=k,f=x,m=!0,v=!1,g=!1,y();const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display=""),I.classList.add("show"),T(),o=setInterval(T,DC.POLL.LOGS)}async function A(k,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(k)}&tail=${x}`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function C(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.text;let E="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(E="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(E="DEBUG",S="var(--muted)");const D=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${E}</span>`;return x.innerHTML=`
|
||||
</div>`}}function L(k,x){h=k,g=x,m=!0,v=!1,f=!1,y();const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),a=setInterval(T,DC.POLL.LOGS)}async function P(k,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(k)}&tail=${x}`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function C(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.text;let E="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(E="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(E="DEBUG",S="var(--muted)");const D=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${E}</span>`;return x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${D}</div>
|
||||
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
|
||||
`,x}async function B(){if(g||!b||!v)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await A(b,k);if(I.error){x.innerHTML=`
|
||||
`,x}async function B(){if(f||!b||!v)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await P(b,k);if(I.error){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(I.error)}</div>
|
||||
@@ -449,7 +449,7 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function $(k,x){b=k,r=x,v=!0,m=!1,g=!1;const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display="none"),I.classList.add("show"),B(),o=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",k=>{const x=k.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&c(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const k=document.getElementById("logs-pause");g?(k.textContent="\u25B6\uFE0F Resume",k.classList.add("paused")):(k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),n())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||n()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!m||!h||(a?y():i(h))}),document.getElementById("logs-modal")?.addEventListener("click",k=>{k.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",k=>{k.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=c})(),(function(){injectModal("service-edit-modal",`
|
||||
</div>`}}function $(k,x){b=k,r=x,v=!0,m=!1,f=!1;const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",k=>{const x=k.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&l(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{f=!f;const k=document.getElementById("logs-pause");f?(k.textContent="\u25B6\uFE0F Resume",k.classList.add("paused")):(k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),n())}),document.getElementById("log-lines")?.addEventListener("change",()=>{f||n()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!m||!h||(o?y():i(h))}),document.getElementById("logs-modal")?.addEventListener("click",k=>{k.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",k=>{k.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=l})(),(function(){injectModal("service-edit-modal",`
|
||||
<div id="service-edit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3 id="service-edit-title">Edit Service</h3>
|
||||
@@ -805,15 +805,15 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
<button id="add-service-create" class="btn-accent">Create Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`)})(),(function(){async function l(m){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(m)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const r=await b.json();if(r.success){const v=document.getElementById("existing-ca-select");return v.innerHTML="",r.cas.length===0?v.innerHTML='<option value="">No CAs found in Caddyfile</option>':(v.innerHTML='<option value="">Select existing CA...</option>',r.cas.forEach(d=>{const a=document.createElement("option");typeof d=="object"?(a.value=d.id,a.textContent=d.displayName||d.name):(a.value=d,a.textContent=d),v.appendChild(a)})),r.data.cas}else throw new Error(r.message)}catch(b){console.error("Error loading CAs:",b);const r=document.getElementById("existing-ca-select");return r.innerHTML='<option value="">Error loading CAs</option>',[]}}function o(m){const{subdomain:b,port:r,ip:v,sslType:d,caName:a,existingCa:p,enableAuth:s,enableCors:e,customHeaders:n,upstreamPath:c,healthCheck:t,timeout:i,tailscaleOnly:y}=m;let u=`${buildDomain(b)} {
|
||||
</div>`)})(),(function(){async function c(m){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(m)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const r=await b.json();if(r.success){const v=document.getElementById("existing-ca-select");return v.innerHTML="",r.cas.length===0?v.innerHTML='<option value="">No CAs found in Caddyfile</option>':(v.innerHTML='<option value="">Select existing CA...</option>',r.cas.forEach(d=>{const o=document.createElement("option");typeof d=="object"?(o.value=d.id,o.textContent=d.displayName||d.name):(o.value=d,o.textContent=d),v.appendChild(o)})),r.data.cas}else throw new Error(r.message)}catch(b){console.error("Error loading CAs:",b);const r=document.getElementById("existing-ca-select");return r.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(m){const{subdomain:b,port:r,ip:v,sslType:d,caName:o,existingCa:p,enableAuth:s,enableCors:e,customHeaders:n,upstreamPath:l,healthCheck:t,timeout:i,tailscaleOnly:y}=m;let u=`${buildDomain(b)} {
|
||||
`;switch(y&&(u+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,u+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),d){case"letsencrypt":break;case"caddy-managed":u+=` tls internal
|
||||
`;break;case"existing-ca":p&&(u+=` tls {
|
||||
ca ${p}
|
||||
}
|
||||
`);break;case"custom-ca":a&&(u+=` tls {
|
||||
ca ${a}
|
||||
`);break;case"custom-ca":o&&(u+=` tls {
|
||||
ca ${o}
|
||||
}
|
||||
`);break}if(s&&(u+=` basicauth {
|
||||
admin $2a$14$hashed_password_here
|
||||
@@ -828,22 +828,21 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
`}),u+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(u+=` health_uri ${t}
|
||||
`),u+=` reverse_proxy ${v}:${r} {
|
||||
`,c&&c!=="/"&&(u+=` rewrite ${c}
|
||||
`,l&&l!=="/"&&(u+=` rewrite ${l}
|
||||
`),i&&i!==30&&(u+=` transport http {
|
||||
`,u+=` dial_timeout ${i}s
|
||||
`,u+=` response_header_timeout ${i}s
|
||||
`,u+=` }
|
||||
`),u+=` }
|
||||
`,u+=`}
|
||||
`,u}async function g(m,b,r=DC.DEFAULTS.TTL){const v=window.getToken(getPrimaryDnsId(),"admin");if(!v)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(m),a=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:b,ttl:r,token:v,server:SITE.dnsIp})});if(!a.ok){const s=await a.text();throw new Error(`DNS API Error: ${a.status} - ${s}`)}const p=await a.json();if(!p.success)throw new Error(`DNS Error: ${p.error||"Unknown error"}`);return p}async function h(m){const b={id:m.subdomain,name:m.name,logo:m.logo||`/assets/${m.subdomain}.png`};m.category&&(b.category=m.category),m.containerId&&(b.containerId=m.containerId);try{const r=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!r.ok){const v=await r.json();throw new Error(v.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(r){throw console.error("Failed to add service to config:",r),r}}async function f(m){const b=document.getElementById("service-subdomain-input").value.trim(),r=document.getElementById("service-ip-input").value.trim()||"localhost",v=document.getElementById("service-port-input").value.trim()||"80",d=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${r}:${v}`,config:m})}),a=await d.json();if(!d.ok||!a.success)throw new Error(a.error||`Caddy API Error: ${d.status}`);return a}window.loadExistingCAs=l,window.generateCaddyConfig=o,window.createDnsRecord=g,window.addServiceToConfig=h,window.addToCaddyfile=f})(),(function(){let l=null;function o(r){l=r;const v=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${r.name}`,document.getElementById("edit-service-name").value=r.name,document.getElementById("edit-service-url-display").textContent=r.url||buildServiceUrl(r.id),document.getElementById("edit-service-logo-preview").src=r.logo||`/assets/${r.id}.png`,document.getElementById("edit-subdomain").value=r.id,document.getElementById("edit-port").value=r.port||"",document.getElementById("edit-ip").value=r.ip||"localhost",document.getElementById("edit-tailscale-only").checked=r.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=r.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=r.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),v.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function h(){if(!l)return;const r=document.getElementById("edit-subdomain").value.trim().toLowerCase(),v=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),a=document.getElementById("edit-ip").value.trim()||"localhost",p=document.getElementById("edit-tailscale-only").checked,s=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!r){showNotification("Subdomain is required","warning");return}const n=l.id,c=[];if(r!==n&&c.push("subdomain"),v&&v!==l.name&&c.push("name"),d&&d!==String(l.port)&&c.push("port"),a!==l.ip&&c.push("ip"),p!==(l.tailscaleOnly||!1)&&c.push("tailscale"),s&&s!==l.logo&&c.push("logo"),e!==(l.category||"")&&c.push("category"),c.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const y=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:n,newSubdomain:r,name:v||l.name,port:d||l.port,ip:a,tailscaleOnly:p,logo:s||void 0,category:e})})).json();if(!y.success)throw new Error(y.error||"Failed to update service");const u=window.APPS.findIndex(w=>w.id===n);u!==-1&&(window.APPS[u]={...window.APPS[u],id:r,name:v||window.APPS[u].name,port:d||window.APPS[u].port,ip:a,tailscaleOnly:p,logo:s||window.APPS[u].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(i){console.error("Error saving service changes:",i),showNotification(`Error saving changes: ${i.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async r=>{const v=r.target.files[0];if(!v)return;if(!v.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async a=>{const p=a.target.result;if(document.getElementById("edit-service-logo-preview").src=p,document.getElementById("edit-logo-url").value=p,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:p})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
`,u}async function f(m,b,r=DC.DEFAULTS.TTL){const v=window.getToken(getPrimaryDnsId(),"admin");if(!v)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(m),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:b,ttl:r,token:v,server:SITE.dnsIp})});if(!o.ok){const s=await o.text();throw new Error(`DNS API Error: ${o.status} - ${s}`)}const p=await o.json();if(!p.success)throw new Error(`DNS Error: ${p.error||"Unknown error"}`);return p}async function h(m){const b={id:m.subdomain,name:m.name,logo:m.logo||`/assets/${m.subdomain}.png`};m.category&&(b.category=m.category),m.containerId&&(b.containerId=m.containerId);try{const r=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!r.ok){const v=await r.json();throw new Error(v.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(r){throw console.error("Failed to add service to config:",r),r}}async function g(m){const b=document.getElementById("service-subdomain-input").value.trim(),r=document.getElementById("service-ip-input").value.trim()||"localhost",v=document.getElementById("service-port-input").value.trim()||"80",d=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${r}:${v}`,config:m})}),o=await d.json();if(!d.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${d.status}`);return o}window.loadExistingCAs=c,window.generateCaddyConfig=a,window.createDnsRecord=f,window.addServiceToConfig=h,window.addToCaddyfile=g})(),(function(){let c=null;function a(r){c=r;const v=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${r.name}`,document.getElementById("edit-service-name").value=r.name,document.getElementById("edit-service-url-display").textContent=r.url||buildServiceUrl(r.id),document.getElementById("edit-service-logo-preview").src=r.logo||`/assets/${r.id}.png`,document.getElementById("edit-subdomain").value=r.id,document.getElementById("edit-port").value=r.port||"",document.getElementById("edit-ip").value=r.ip||"localhost",document.getElementById("edit-tailscale-only").checked=r.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=r.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=r.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),v.classList.add("show")}function f(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const r=document.getElementById("edit-subdomain").value.trim().toLowerCase(),v=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",p=document.getElementById("edit-tailscale-only").checked,s=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!r){showNotification("Subdomain is required","warning");return}const n=c.id,l=[];if(r!==n&&l.push("subdomain"),v&&v!==c.name&&l.push("name"),d&&d!==String(c.port)&&l.push("port"),o!==c.ip&&l.push("ip"),p!==(c.tailscaleOnly||!1)&&l.push("tailscale"),s&&s!==c.logo&&l.push("logo"),e!==(c.category||"")&&l.push("category"),l.length===0){f();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const y=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:n,newSubdomain:r,name:v||c.name,port:d||c.port,ip:o,tailscaleOnly:p,logo:s||void 0,category:e})})).json();if(!y.success)throw new Error(y.error||"Failed to update service");const u=window.APPS.findIndex(w=>w.id===n);u!==-1&&(window.APPS[u]={...window.APPS[u],id:r,name:v||window.APPS[u].name,port:d||window.APPS[u].port,ip:o,tailscaleOnly:p,logo:s||window.APPS[u].logo,category:e||void 0}),f(),window.buildGrid(),window.refreshAll()}catch(i){console.error("Error saving service changes:",i),showNotification(`Error saving changes: ${i.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async r=>{const v=r.target.files[0];if(!v)return;if(!v.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async o=>{const p=o.target.result;if(document.getElementById("edit-service-logo-preview").src=p,document.getElementById("edit-logo-url").value=p,c)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:p})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
|
||||
This will:
|
||||
1. Pull the latest image
|
||||
2. Stop the container
|
||||
3. Recreate with same settings
|
||||
|
||||
The service will be briefly unavailable.`))try{a&&(a.textContent="\u{1F504}",a.disabled=!0,a.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${r}/update`,{method:"POST"})).json();if(e.success){const n=window.APPS.find(c=>c.id===d);n&&e.newContainerId&&(n.containerId=e.newContainerId),a&&(a.textContent="\u2705",a.title="Updated successfully!",setTimeout(()=>{a.textContent=p,a.disabled=!1,a.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${v} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(s){console.error("Update error:",s),a&&(a.textContent="\u274C",a.title="Update failed",setTimeout(()=>{a.textContent=p,a.disabled=!1,a.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${v}: ${s.message}`,"error")}}async function b(r,v){const d=window.APPS.find(u=>u.id===r),a=d?buildDomain(d.id):null,p=d?.containerId,s=await f(v||r,p,d?.containerId);if(s===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(s&&p)try{const u=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${u.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(u){console.error("App removal error:",u)}else if(s&&a){try{const u=d?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(a)}&type=A&ipAddress=${encodeURIComponent(u)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(u){e.dns=u.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(u){e.caddy=u.message}}const n=window.APPS.findIndex(u=>u.id===r);n>-1&&(window.APPS.splice(n,1),e.dashboard=!0);try{const u=safeGetJSON("custom-apps",[]),w=u.findIndex(T=>T.id===r);w>-1&&(u.splice(w,1),safeSet("custom-apps",JSON.stringify(u)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(u){e.service=u.message}window.buildGrid(),window.refreshAll();let c=!1,t=[];e.dashboard||(c=!0,t.push("\u2717 Failed to remove from dashboard"));const i=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=u=>!u||i.some(w=>u.toLowerCase().includes(w.toLowerCase()));e.container&&!y(e.container)&&(c=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(c=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(c=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(c=!0,t.push(`\u26A0 Service File: ${e.service}`)),c&&showNotification(`Error deleting "${v||r}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=o,window.showDeleteModal=f,window.updateContainer=m,window.deleteService=b})(),(function(){function l(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function o(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",n=document.getElementById("service-ip-input").value||h.lan||"localhost",c=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,i=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,u=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${n}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const k={subdomain:e,port:c,ip:n,sslType:t,caName:i,existingCa:y,enableAuth:u,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:A,timeout:C},x=window.generateCaddyConfig(k),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const i=await t.json();i.lan&&(h.lan=i.lan),i.tailscale&&(h.tailscale=i.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),n=document.getElementById("quick-ip-tailscale");e&&(h.lan?(e.dataset.ip=h.lan,e.textContent=`LAN (${h.lan})`,e.title=`LAN IP: ${h.lan}`):e
Line truncated
|
||||
The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${r}/update`,{method:"POST"})).json();if(e.success){const n=window.APPS.find(l=>l.id===d);n&&e.newContainerId&&(n.containerId=e.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${v} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(s){console.error("Update error:",s),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${v}: ${s.message}`,"error")}}async function b(r,v){const d=window.APPS.find(u=>u.id===r),o=d?buildDomain(d.id):null,p=d?.containerId,s=await g(v||r,p,d?.containerId);if(s===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(s&&p)try{const u=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${u.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(u){console.error("App removal error:",u)}else if(s&&o){try{const u=d?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(u)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(u){e.dns=u.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(u){e.caddy=u.message}}const n=window.APPS.findIndex(u=>u.id===r);n>-1&&(window.APPS.splice(n,1),e.dashboard=!0);try{const u=safeGetJSON("custom-apps",[]),w=u.findIndex(T=>T.id===r);w>-1&&(u.splice(w,1),safeSet("custom-apps",JSON.stringify(u)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(u){e.service=u.message}window.buildGrid(),window.refreshAll();let l=!1,t=[];e.dashboard||(l=!0,t.push("\u2717 Failed to remove from dashboard"));const i=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=u=>!u||i.some(w=>u.toLowerCase().includes(w.toLowerCase()));e.container&&!y(e.container)&&(l=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(l=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(l=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(l=!0,t.push(`\u26A0 Service File: ${e.service}`)),l&&showNotification(`Error deleting "${v||r}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=g,window.updateContainer=m,window.deleteService=b})(),(function(){function c(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function a(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function f(){const e=document.getElementById("service-subdomain-input").value||"subdomain",n=document.getElementById("service-ip-input").value||h.lan||"localhost",l=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,i=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,u=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${n}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const k={subdomain:e,port:l,ip:n,sslType:t,caName:i,existingCa:y,enableAuth:u,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:C},x=window.generateCaddyConfig(k),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function g(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const i=await t.json();i.lan&&(h.lan=i.lan),i.tailscale&&(h.tailscale=i.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),n=document.getElementById("quick-ip-tailscale");e&&(h.lan?(e.dataset.ip=h.lan,e.textContent=`LAN (${h.lan})`,e.title=`LAN IP: ${h.lan}`):e
Line truncated
|
||||
<span style="color: #4caf50;">\u2713 Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
|
||||
`,y.disabled=!1):w.installed?(i.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',y.disabled=!0):(i.innerHTML='<span style="color: var(--muted);">Not available</span>',y.disabled=!0)}catch{i.innerHTML='<span style="color: var(--muted);">Could not check</span>',y.disabled=!0}y.checked=!1,g()}function r(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),c=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),i=document.getElementById("tab-local"),y=document.getElementById("tab-external");function u(){e.checked?(c.style.display="grid",t.style.display="none",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(c.style.display="none",t.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function v(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),c=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&n&&(n.value=L),c&&(c.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),n?.addEventListener("input",()=>{t=n.value!==l(e?.value||"");const L=n.value.trim()||l(e?.value||"");c&&(c.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const i=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;i?.addEventListener("input",()=>{const L=l(i.value);!T&&y&&(y.value=L);const A=y?.value||L;u&&(u.textContent=A?`\u2192 ${buildDomain(A)}`:""),w&&(w.textContent=A?buildDomain(A):"")}),y?.addEventListener("input",()=>{T=y.value!==l(i?.value||"");const L=y.value.trim()||l(i?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function d(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),c=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),i=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,A=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!c){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(c);try{const B={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const P=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=P.success?"created":P.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const E={subdomain:c,externalUrl:n,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},P=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();B.caddy=P.success?"created":P.error||"failed"}catch(E){B.caddy=E.message}const $={id:c,name:e,url:`https://${C}`,externalUrl:n,logo:t||i||"\u{1F310}",isExternal:!0,isCustom:!0};A&&($.category=A),window.APPS.push($),B.dashboard=!0;const k=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(E=>!k.includes(E.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(E){console.warn("Failed to save to services.json:",E)}window.buildGrid(),window.refreshAll(),a();const I=[`External service "${e}" added!`];y&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI
Line truncated
|
||||
`,y.disabled=!1):w.installed?(i.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',y.disabled=!0):(i.innerHTML='<span style="color: var(--muted);">Not available</span>',y.disabled=!0)}catch{i.innerHTML='<span style="color: var(--muted);">Could not check</span>',y.disabled=!0}y.checked=!1,f()}function r(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),l=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),i=document.getElementById("tab-local"),y=document.getElementById("tab-external");function u(){e.checked?(l.style.display="grid",t.style.display="none",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(l.style.display="none",t.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function v(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),l=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=c(e.value);!t&&n&&(n.value=L),l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()}),n?.addEventListener("input",()=>{t=n.value!==c(e?.value||"");const L=n.value.trim()||c(e?.value||"");l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()});const i=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;i?.addEventListener("input",()=>{const L=c(i.value);!T&&y&&(y.value=L);const P=y?.value||L;u&&(u.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),y?.addEventListener("input",()=>{T=y.value!==c(i?.value||"");const L=y.value.trim()||c(i?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function d(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),l=(document.getElementById("external-service-subdomain").value.trim()||c(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),i=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!l){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(l);try{const B={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const E={subdomain:l,externalUrl:n,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(E){B.caddy=E.message}const $={id:l,name:e,url:`https://${C}`,externalUrl:n,logo:t||i||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const k=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(E=>!k.includes(E.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(E){console.warn("Failed to save to services.json:",E)}window.buildGrid(),window.refreshAll(),o();const I=[`External service "${e}" added!`];y&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
@@ -90,44 +90,44 @@
|
||||
<button id="logo-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("logo-modal"),I=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),M=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),B=document.getElementById("logo-upload-single"),x=document.querySelector("#brand .brand-logo-dark"),$=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),T=document.getElementById("dashboard-title"),S=DC.NAME;let L=null,j=null,C=null,N="left",R=S;M?.addEventListener("change",()=>{M.checked?(P.style.display="none",z.style.display="",L=null,j=null):(P.style.display="flex",z.style.display="none",C=null)});function U(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{U(t.target.files[0],e=>{L=e,I.src=e,E.textContent="New dark logo ready to save"})}),v?.addEventListener("change",t=>{U(t.target.files[0],e=>{j=e,A.src=e,E.textContent="New light logo ready to save"})}),B?.addEventListener("change",t=>{U(t.target.files[0],e=>{C=e,I.src=e,A.src=e,E.textContent="New logo ready to save (both themes)"})});function u(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){R=t||S,document.title=R;const e=document.querySelector(".dashboard-title");e&&(e.textContent=R)}async function h(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(x.src=e.customLogoDark,I.src=e.customLogoDark),e.customLogoLight&&($.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(x.src=e.customLogo,$.src=e.customLogo,I.src=e.customLogo,A.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(N=e.position,u(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{N=t.dataset.pos,u(N)})}),document.getElementById("brand")?.addEventListener("click",()=>{L=null,j=null,C=null,D&&(D.value=""),v&&(v.value=""),B&&(B.value=""),M&&(M.checked=!1),P.style.display="flex",z.style.display="none",I.src=x.src,A.src=$.src;const t=x.src.includes("custom-logo")||$.src.includes("custom-logo");E.textContent=t?"Using custom logo":"Using default logos",u(N),T.value=R,f.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=T.value.trim()||S,e={position:N,dashboardTitle:t};M?.checked&&C?(e.dataDark=C,e.dataLight=C):(L&&(e.dataDark=L),j&&(e.dataLight=j));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(x.src=o.pathDark+i,I.src=o.pathDark+i),o.pathLight&&($.src=o.pathLight+i,A.src=o.pathLight+i),u(N),m(t),f.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
</div>`);const h=document.getElementById("logo-modal"),S=document.getElementById("logo-preview-dark"),O=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),T=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),I=DC.NAME;let C=null,j=null,M=null,D="left",x=I;N?.addEventListener("change",()=>{N.checked?(R.style.display="none",z.style.display="",C=null,j=null):(R.style.display="flex",z.style.display="none",M=null)});function A(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=a=>e(a.target.result),n.readAsDataURL(t)}P?.addEventListener("change",t=>{A(t.target.files[0],e=>{C=e,S.src=e,E.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{A(t.target.files[0],e=>{j=e,O.src=e,E.textContent="New light logo ready to save"})}),L?.addEventListener("change",t=>{A(t.target.files[0],e=>{M=e,S.src=e,O.src=e,E.textContent="New logo ready to save (both themes)"})});function y(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){x=t||I,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function b(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,S.src=e.customLogoDark),e.customLogoLight&&(T.src=e.customLogoLight,O.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,T.src=e.customLogo,S.src=e.customLogo,O.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(D=e.position,y(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{D=t.dataset.pos,y(D)})}),document.getElementById("brand")?.addEventListener("click",()=>{C=null,j=null,M=null,P&&(P.value=""),g&&(g.value=""),L&&(L.value=""),N&&(N.checked=!1),R.style.display="flex",z.style.display="none",S.src=w.src,O.src=T.src;const t=w.src.includes("custom-logo")||T.src.includes("custom-logo");E.textContent=t?"Using custom logo":"Using default logos",y(D),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=B.value.trim()||I,e={position:D,dashboardTitle:t};N?.checked&&M?(e.dataDark=M,e.dataLight=M):(C&&(e.dataDark=C),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const a=await n.json(),l="?t="+Date.now();a.pathDark&&(w.src=a.pathDark+l,S.src=a.pathDark+l),a.pathLight&&(T.src=a.pathLight+l,O.src=a.pathLight+l),y(D),m(t),h.classList.remove("show")}else{const a=await n.json();showNotification("Failed to save: "+a.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(x.src="/assets/dashcaddy-logo-dark.png",$.src="/assets/dashcaddy-logo-light.png",I.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",L=null,j=null,C=null,T.value=S,m(S),N="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),d=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(f,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),H=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let d=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function b(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),H.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}r?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const a=new FileReader;a.onload=o=>{d=o.target.result,g.src=d,H.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(t.ok){const e=await t.json();c.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),H.textContent="Using custom favicon",d=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),b(),h();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{f.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(f,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(f,I){const A=Intl.supportedValuesOf("timeZone"),E=I||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f.innerHTML="";for(const M of A){const P=document.createElement("option");P.value=M,P.textContent=M.replace(/_/g," "),M===E&&(P.selected=!0),f.appendChild(P)}},(function(){let f="homelab",I=null;async function A(){try{const m=await fetch("/api/v1/config");if(m.ok&&(I=await m.json(),I&&I.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(m){console.warn("Could not fetch server config, checking localStorage fallback:",m.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function M(u){document.querySelectorAll(".setup-step").forEach(h=>{h.style.display="none"});const m=document.getElementById(u);m&&(m.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let m='<div style="display: grid; gap: 20px;">';if(f==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",H=document.getElementById("setup-ca-name")?.value?.trim()||"",r=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;m+=`
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",T.src="/assets/dashcaddy-logo-light.png",S.src="/assets/dashcaddy-logo-dark.png",O.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",C=null,j=null,M=null,B.value=I,m(I),D="left",y("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),l=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),l&&(l.textContent="Using DashCaddy favicon"),r=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const p=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),d=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(d.rel="icon",d.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(d));async function f(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(d.href=e.customFavicon+"?t="+Date.now(),p.src=e.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const n=new FileReader;n.onload=a=>{r=a.target.result,p.src=r,u.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(t.ok){const e=await t.json();d.href=e.path+"?t="+Date.now(),p.src=e.path+"?t="+Date.now(),u.textContent="Using custom favicon",r=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),f(),b();const v=document.getElementById("settings-timezone");v&&(new MutationObserver(()=>{h.classList.contains("show")&&v.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(v,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=v.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const a=await n.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(h,S){const O=Intl.supportedValuesOf("timeZone"),E=S||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of O){const R=document.createElement("option");R.value=N,R.textContent=N.replace(/_/g," "),N===E&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",S=null;async function O(){try{const A=await fetch("/api/v1/config");if(A.ok&&(S=await A.json(),S&&S.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(A){console.warn("Could not fetch server config, checking localStorage fallback:",A.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}O();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function N(x){document.querySelectorAll(".setup-step").forEach(y=>{y.style.display="none"});const A=document.getElementById(x);A&&(A.style.display="block")}function R(){const x=document.getElementById("setup-summary-content");if(!x)return;let A='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const m=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",u=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;A+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>TLD:</strong> ${g}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${H}</div>
|
||||
<div><strong>DNS Server:</strong> ${r}:${c}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${g}, https://nextcloud${g}</div>
|
||||
<div><strong>TLD:</strong> ${m}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${b}</div>
|
||||
<div><strong>DNS Server:</strong> ${p}:${u}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${m}, https://nextcloud${m}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(f==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";m+=`
|
||||
`}else if(h==="simple"){const m=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";A+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Access Method:</strong> IP:Port only</div>
|
||||
<div><strong>Default IP:</strong> ${g}</div>
|
||||
<div><strong>Default IP:</strong> ${m}</div>
|
||||
<div><strong>SSL:</strong> None (HTTP only)</div>
|
||||
<div><strong>Example URLs:</strong> http://${g}:8080, http://${g}:3000</div>
|
||||
<div><strong>Example URLs:</strong> http://${m}:8080, http://${m}:3000</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(f==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",H=document.getElementById("setup-public-email")?.value?.trim()||"",r=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=r==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;m+=`
|
||||
`}else if(h==="public"){const m=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u=p==="subdirectory"?`https://${m}/sonarr, https://${m}/grafana`:`https://sonarr.${m}, https://grafana.${m}`;A+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Domain:</strong> ${g}</div>
|
||||
<div><strong>Domain:</strong> ${m}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${H}</div>
|
||||
<div><strong>Routing:</strong> ${r==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${c}</div>
|
||||
<div><strong>Email:</strong> ${b}</div>
|
||||
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${u}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}const h=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";m+=`
|
||||
`}const y=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";A+=`
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${h.replace(/_/g," ")}</div>
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${y.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,m+="</div>",u.innerHTML=m,M("setup-step-summary")}async function z(u){try{const m=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return m.ok?(await m.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${m.status}`),{function:"saveConfigToServer"}),!1)}catch(m){return errorHandler.logError("[SetupWizard] Save Config",m,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:f,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(f==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const H=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:H,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else f==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):f==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const m=await z(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const h=f==="homelab"?"Professional Home Lab":f==="simple"?"Simple Setup":"Public Server",g=m?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${h}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(u){u.preventDefault();const m=document.querySelector('input[name="config-type"]:checked');m&&(f=m.value),M(f==="homelab"?"setup-step-homelab":f==="simple"?"setup-step-simple":f==="public"?"setup-step-public":"setup-step-homelab")});const B=document.getElementById("setup-skip");B&&(B.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const x=document.getElementById("setup-tld");x&&(x.oninput=function(u){const m=u.target.value||".home",h=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");h&&(h.textContent=m),g&&(g.textContent=m)});const $=document.getElementById("setup-homelab-back");$&&($.onclick=function(u){u.preventDefault(),M("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-tld")?.value?.trim()||"",h=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!m||!m.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!h){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const T=document.getElementById("setup-simple-back");T&&(T.onclick=function(u){u.preventDefault(),M("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var m=document.getElementById("dns-requirement-note");m&&(m.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const L=document.getElementById("setup-public-back");L&&(L.onclick=function(u){u.preventDefault(),M("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-public-domain")?.value?.trim()||"",h=document.getElementById("setup-public-email")?.value?.trim()||"";if(!m){showNotification("Please enter your domain name","warning");return}if(!h||!h.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(u){u.preventDefault(),f==="homelab"?M("setup-step-homela
Line truncated
|
||||
`,A+="</div>",x.innerHTML=A,N("setup-step-summary")}async function z(x){try{const A=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return A.ok?(await A.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${A.status}`),{function:"saveConfigToServer"}),!1)}catch(A){return errorHandler.logError("[SetupWizard] Save Config",A,{function:"saveConfigToServer"}),!1}}async function P(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:b,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const A=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const y=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",m=A?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${y}. Settings saved to: ${m}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(x){x.preventDefault();const A=document.querySelector('input[name="config-type"]:checked');A&&(h=A.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(x){const A=x.target.value||".home",y=document.getElementById("tld-preview"),m=document.getElementById("tld-preview-2");y&&(y.textContent=A),m&&(m.textContent=A)});const T=document.getElementById("setup-homelab-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const A=document.getElementById("setup-tld")?.value?.trim()||"",y=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!A||!A.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!y){showNotification("Please enter a Certificate Authority name","warning");return}if(!m){showNotification("Please enter your DNS server IP address","warning");return}R()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-simple-next");I&&(I.onclick=function(x){x.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var A=document.getElementById("dns-requirement-note");A&&(A.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const C=document.getElementById("setup-public-back");C&&(C.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const A=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"";if(!A){showNotification("Please enter your domain name","warning");return}if(!y||!y.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela
Line truncated
|
||||
<div class="app-selector-content">
|
||||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||||
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const I="custom-apps";let A=null,E=null;const M=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return A=c.templates,E=c.categories,!0}catch(r){f.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function D(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return f.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(r){try{const d=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){f.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function B(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await z()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[d,b]of Object.entries(A)){const y=b.category||"Other";r[y]||(r[y]=[]),r[y].push({id:d,...b})}const c=E?Object.keys(E):Object.keys(r).sort();for(const d of c){const b=r[d];if(!b||b.length===0)continue;b.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const t=E?.[d]||{};y.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(d)}`,t.color&&(y.style.borderBottomColor=t.color),P.appendChild(y),b.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",s=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
|
||||
</div>`);const S="custom-apps";let O=null,E=null;const N=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function z(){try{const d=await(await fetch("/api/v1/apps/templates")).json();if(d.success)return O=d.templates,E=d.categories,!0}catch(s){h.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function P(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(d){return h.logError("[AppSelector] Check Port",d,{function:"checkPortAvailability"}),{available:!0}}}async function g(s){try{const r=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(r.success)return r.suggestedPort}catch(d){h.logError("[AppSelector] Get Suggested Port",d,{function:"getSuggestedPort"})}return s}async function L(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!O&&!await z()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const s={};for(const[r,f]of Object.entries(O)){const v=f.category||"Other";s[v]||(s[v]=[]),s[v].push({id:r,...f})}const d=E?Object.keys(E):Object.keys(s).sort();for(const r of d){const f=s[r];if(!f||f.length===0)continue;f.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const v=document.createElement("div");v.className="app-category-header";const t=E?.[r]||{};v.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(r)}`,t.color&&(v.style.borderBottomColor=t.color),R.appendChild(v),f.forEach(e=>{const n=document.createElement("div");n.className="app-option";const a=e.isDashboardWidget,l=a&&safeGet("widget-"+e.id+"-enabled")!=="false",o=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${l?"#2ecc7130":"#e74c3c30"}; color: ${l?"#2ecc71":"#e74c3c"}; font-weight: 600;">${l?"ON":"OFF"}</div>`:"",i=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
|
||||
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
||||
${n}${s}
|
||||
`,o?a.onclick=()=>x(e,a):a.onclick=()=>$(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function x(r,c){const d="widget-"+r.id+"-enabled",y=!(safeGet(d)!=="false");safeSet(d,String(y));const t=r.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function $(r){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),b=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${r.name} container:
|
||||
${o}${i}
|
||||
`,a?n.onclick=()=>w(e,n):n.onclick=()=>T(e),R.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function w(s,d){const r="widget-"+s.id+"-enabled",v=!(safeGet(r)!=="false");safeSet(r,String(v));const t=s.widgetSelector;if(t){const n=document.querySelector(t);n&&(n.style.display=v?"":"none")}const e=d.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=v?"ON":"OFF",e.style.background=v?"#2ecc7130":"#e74c3c30",e.style.color=v?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${v?"enabled":"disabled"}`,"success",2e3)}async function T(s){const d=document.getElementById("app-deploy-modal"),r=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),v=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${s.name} container:
|
||||
|
||||
Container: ${V.name}
|
||||
Status: ${V.status}
|
||||
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
|
||||
Would you like to use this existing container?
|
||||
|
||||
Click OK to configure DNS/Caddy for the existing container.
|
||||
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}d.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");b.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const W=r.subpathSupport||"strip";W==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">ⓘ '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),p=document.querySelector(`input[name="dns-type"][value="${s}"]`),w=document.querySelector(`input[name="ssl-type"][value="${l}"]`);p?p.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,w?w.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const O=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const W=r.mediaMount?.containerPath,G=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}r.textContent=`Deploy ${s.name}`;const l=s.subdomain||s.id.replace(/-/g,"");f.value=l;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=s.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">ⓘ '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),$=document.querySelector(`input[name="dns-type"][value="${i}"]`),H=document.querySelector(`input[name="ssl-type"][value="${c}"]`);$?$.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,H?H.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),F=q?.querySelector("div");if(q&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(F.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const _=s.mediaMount?.containerPath,W=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
|
||||
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
||||
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
|
||||
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await D(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
|
||||
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
|
||||
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?o.innerHTML=`
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await P(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
||||
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
|
||||
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=`
|
||||
<span style="color: #4caf50;">Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
|
||||
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=b.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}y.textContent=K}b.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),M.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function k(r){const c=r.appTemplate,d=safeGetJSON(I,[]),b=c._useExisting&&c._existingContainer,y=d.find(t=>t.id===r.subdomain);if(!(y&&!b&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(y){const t=d.indexOf(y);d.splice(t,1),safeSet(I,JSON.stringify(d))}if(b)r.port=c._existingContainer.primaryPort;else{const t=r.port||c.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await v(c.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
|
||||
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=f.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}v.textContent=K}f.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),d.classList.add("show"),d.dataset.appTemplate=JSON.stringify(s)}async function k(s){const d=s.appTemplate,r=safeGetJSON(S,[]),f=d._useExisting&&d._existingContainer,v=r.find(t=>t.id===s.subdomain);if(!(v&&!f&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(v){const t=r.indexOf(v);r.splice(t,1),safeSet(S,JSON.stringify(r))}if(f)s.port=d._existingContainer.primaryPort;else{const t=s.port||d.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const n=await g(d.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
|
||||
Would you like to use port ${a} instead?`))r.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(b?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const t={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};b&&(t.config.useExisting=!0,t.config.existingContainerId=c._existingContainer.id,t.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(t.config.port=c._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:a.containerId,url:a.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};d.push(o),safeSet(I,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${c.name} configured with existing container!
|
||||
URL: ${a.url}`:`${c.name} deployed successfully!
|
||||
URL: ${a.url}`;a.warning&&(i+=`
|
||||
Would you like to use port ${n} instead?`))s.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${d.name} with existing container...`:`Deploying ${d.name}...`,"info",0);try{const t={appId:d.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||d.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};f&&(t.config.useExisting=!0,t.config.existingContainerId=d._existingContainer.id,t.config.existingPort=d._existingContainer.primaryPort,!s.port&&d._existingContainer.primaryPort&&(t.config.port=d._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(n.success){const a={id:s.subdomain,name:d.name,logo:`/assets/${d.id}.png`,containerId:n.containerId,url:n.url,ip:s.ip,appTemplate:d.id,tailscaleOnly:s.tailscaleOnly||!1};r.push(a),safeSet(S,JSON.stringify(r)),window.APPS&&!window.APPS.some(o=>o.id===d.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let l=n.usedExisting?`${d.name} configured with existing container!
|
||||
URL: ${n.url}`:`${d.name} deployed successfully!
|
||||
URL: ${n.url}`;n.warning&&(l+=`
|
||||
|
||||
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,a.url&&a.url.startsWith("https://")&&T(a.url,c.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){f.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${t.message}`,"error",8e3)}}}async function T(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const b=12,y=async()=>{d++;try{const t=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<b?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(I,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{B(),M.classList.add("show")}),wireModal(M,document.getElementById("app-selector-cancel"));const L=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(L.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{d.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const b={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:d.length>0?d:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!b.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}L.classList.remove("show"),k(b)}),wireModal(L);const j=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),N=document.getElementById("folder-browser-list"),R=document.getElementById("folder-browser-selected"),U=document.getElementById("folder-browser-selected-list");let u="",m=[],h=null;window.openFolderBrowser=function(r){h=r,m=r.value.split(",").map(c=>c.trim()).filter(c=>c),u="",H(),g(""),j.classList.add("show")};async function g(r){C.textContent=r||"Select a drive...",N.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const d=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!d.success){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}u=d.path||"",C.textContent=u||"Select a drive...";let b="";d.parent&&d.parent!==d.path&&(b+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
\u26A0 Warning: ${n.warning}`),showNotification(l,"success",8e3),delete d._useExisting,delete d._existingContainer,n.url&&n.url.startsWith("https://")&&B(n.url,d.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${d.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${d.name}: ${t.message}`,"error",8e3)}}}async function B(s,d){showNotification(`\u23F3 Generating SSL certificate for ${d}...`,"warning",6e4);let r=0;const f=12,v=async()=>{r++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${d} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return r<f?setTimeout(v,5e3):showNotification(`\u26A0\uFE0F ${d} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(v,3e3)}function I(){safeGetJSON(S,[]).forEach(d=>{window.APPS.some(r=>r.id===d.id)||window.APPS.push(d)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const C=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(C.dataset.appTemplate),d=document.getElementById("deploy-media-path").value.trim(),r=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(v=>{r.push({hostPath:v.value.trim(),containerPath:v.dataset.containerPath})});const f={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:d||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:r.length>0?r:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!d){showNotification("Please enter a media library path for this application","warning");return}C.classList.remove("show"),k(f)}),wireModal(C);const j=document.getElementById("folder-browser-modal"),M=document.getElementById("folder-browser-path"),D=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),A=document.getElementById("folder-browser-selected-list");let y="",m=[],b=null;window.openFolderBrowser=function(s){b=s,m=s.value.split(",").map(d=>d.trim()).filter(d=>d),y="",u(),p(""),j.classList.add("show")};async function p(s){M.textContent=s||"Select a drive...",D.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const r=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!r.success){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(r.error)}</div>`;return}y=r.path||"",M.textContent=y||"Select a drive...";let f="";r.parent&&r.parent!==r.path&&(f+=`<div class="folder-item" data-path="${escapeHtml(r.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
||||
<span style="color: var(--muted);">.. Parent Directory</span>
|
||||
</div>`),d.items.length===0&&!d.parent?b+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':d.items.length===0?b+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(y=>{const t=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(y.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";b+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
|
||||
</div>`),r.items.length===0&&!r.parent?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':r.items.length===0?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':r.items.forEach(v=>{const t=v.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(v.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<div class="folder-item" data-path="${escapeHtml(v.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
|
||||
<span style="font-size: 1.2rem;">${t}</span>
|
||||
<span style="flex: 1;">${escapeHtml(y.name)}</span>
|
||||
<span style="flex: 1;">${escapeHtml(v.name)}</span>
|
||||
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
||||
</div>`}),N.innerHTML=b,N.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{g(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const t=m.includes(y.dataset.path);y.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function H(){if(m.length===0){R.style.display="none";return}R.style.display="block",U.innerHTML=m.map(r=>`
|
||||
</div>`}),D.innerHTML=f,D.querySelectorAll(".folder-item").forEach(v=>{v.addEventListener("click",()=>{p(v.dataset.path)}),v.addEventListener("mouseenter",()=>{v.style.background="var(--card-bg)"}),v.addEventListener("mouseleave",()=>{const t=m.includes(v.dataset.path);v.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(d){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(d.message)}</div>`}}function u(){if(m.length===0){x.style.display="none";return}x.style.display="block",A.innerHTML=m.map(s=>`
|
||||
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
${escapeHtml(r)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
${escapeHtml(s)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
</span>
|
||||
`).join("")}window.removeSelectedFolder=function(r){m=m.filter(c=>c!==r),H(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!m.includes(u)&&(m.push(u),H(),g(u))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{h&&(h.value=m.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
`).join("")}window.removeSelectedFolder=function(s){m=m.filter(d=>d!==s),u(),p(y)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{y&&!m.includes(y)&&(m.push(y),u(),p(y))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{b&&(b.value=m.join(", ")),j.classList.remove("show")}),I()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
|
||||
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
|
||||
|
||||
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let f=null,I=null,A=null,E=1,M=!1;const P=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(P,z);async function B(){try{const u=await fetch("/api/v1/recipes/templates"),m=await u.json();if(m.success)return f=m.templates,I=m.categories,!0;if(u.status===403)return M=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function x(){try{M=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{M=!1}return M}window.renderRecipeCards=async function(u){await x();let m;if(M&&f?m=f:m=$(),!m||m.length===0)return;const h=document.createElement("div");h.className="app-category-header",h.innerHTML="\u{1F9EA} Recipes",h.style.borderBottomColor="#8e44ad",u.appendChild(h);const g=Array.isArray(m)?m:Object.values(m);g.sort((H,r)=>(r.popularity||0)-(H.popularity||0));for(const H of g){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${H.componentCount||H.components?.length||"?"} apps</div>`,d=M?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
|
||||
</div>`);let h=null,S=null,O=null,E=1,N=!1;const R=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(R,z);async function L(){try{const y=await fetch("/api/v1/recipes/templates"),m=await y.json();if(m.success)return h=m.templates,S=m.categories,!0;if(y.status===403)return N=!1,!1}catch(y){console.warn("Failed to fetch recipe templates:",y.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(y){await w();let m;if(N&&h?m=h:m=T(),!m||m.length===0)return;const b=document.createElement("div");b.className="app-category-header",b.innerHTML="\u{1F9EA} Recipes",b.style.borderBottomColor="#8e44ad",y.appendChild(b);const p=Array.isArray(m)?m:Object.values(m);p.sort((u,s)=>(s.popularity||0)-(u.popularity||0));for(const u of p){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const d=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${u.componentCount||u.components?.length||"?"} apps</div>`,r=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
|
||||
${r}
|
||||
<div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(u.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(u.description||"")}</div>
|
||||
${d}
|
||||
<div class="app-option-icon">${escapeHtml(H.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(H.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(H.description||"")}</div>
|
||||
${c}
|
||||
`,r.onclick=()=>{if(!M){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(H)},u.appendChild(r)}};function $(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(u){A=u,E=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,T(),S(),P.classList.add("show")}function T(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const m=parseInt(u.dataset.step);u.classList.toggle("active",m===E),u.classList.toggle("completed",m<E)});for(let u=1;u<=4;u++){const m=document.getElementById(`recipe-step-${u}`);m&&(m.style.display=u===E?"":"none")}D.style.display=E>1&&E<4?"":"none",E===4?(v.style.display="none",z.textContent="Close"):E===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const m=A.components||[];for(const h of m){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const H=h.required,r=h.internal;g.innerHTML=`
|
||||
<input type="checkbox" ${H?"checked disabled":"checked"} data-component-id="${h.id}"
|
||||
`,s.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(u)},y.appendChild(s)}};function T(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(y){O=y,E=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${y.name}`,B(),I(),R.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(y=>{const m=parseInt(y.dataset.step);y.classList.toggle("active",m===E),y.classList.toggle("completed",m<E)});for(let y=1;y<=4;y++){const m=document.getElementById(`recipe-step-${y}`);m&&(m.style.display=y===E?"":"none")}P.style.display=E>1&&E<4?"":"none",E===4?(g.style.display="none",z.textContent="Close"):E===3?(g.textContent="\u{1F680} Deploy",g.style.display="",z.textContent="Cancel"):(g.textContent="Next",g.style.display="",z.textContent="Cancel")}function I(){const y=document.getElementById("recipe-component-list");y.innerHTML="";const m=O.components||[];for(const b of m){const p=document.createElement("div");p.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=b.required,s=b.internal;p.innerHTML=`
|
||||
<input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${b.id}"
|
||||
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(h.role||h.id)}</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(b.role||b.id)}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">
|
||||
${h.templateRef?escapeHtml(h.templateRef):"Built-in"}
|
||||
${H?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
${b.templateRef?escapeHtml(b.templateRef):"Built-in"}
|
||||
${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
</div>
|
||||
${h.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(h.note)}</div>`:""}
|
||||
${b.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(b.note)}</div>`:""}
|
||||
</div>
|
||||
`,u.appendChild(g)}}function L(){const u=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),h=A.sharedVolumes;if(h&&Object.keys(h).length>0){u.style.display="",m.innerHTML="";for(const[g,H]of Object.entries(h)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(H.label||g)}</label>
|
||||
<input type="text" data-volume-key="${g}" value="${escapeHtml(H.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(H.defaultPath||"/path")}"
|
||||
`,y.appendChild(p)}}function C(){const y=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),b=O.sharedVolumes;if(b&&Object.keys(b).length>0){y.style.display="",m.innerHTML="";for(const[p,u]of Object.entries(b)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(u.label||p)}</label>
|
||||
<input type="text" data-volume-key="${p}" value="${escapeHtml(u.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(u.defaultPath||"/path")}"
|
||||
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
|
||||
<div class="form-hint-sm">${escapeHtml(H.description||"")}</div>
|
||||
`,m.appendChild(r)}}else u.style.display="none"}function j(){const u=document.getElementById("recipe-review-content"),m=C(),h=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};h.forEach(d=>{g[d.dataset.volumeKey]=d.value});const H=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div>
|
||||
<div class="form-hint-sm">${escapeHtml(u.description||"")}</div>
|
||||
`,m.appendChild(s)}}else y.style.display="none"}function j(){const y=document.getElementById("recipe-review-content"),m=M(),b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};b.forEach(r=>{p[r.dataset.volumeKey]=r.value});const u=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",d=document.getElementById("recipe-tailscale").checked;y.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(O.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(O.description||"")}</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong>Components (${m.length}):</strong>
|
||||
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
||||
${m.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(d.role||d.id)}</strong> ${d.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
||||
${m.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${Object.keys(g).length>0?`<div style="margin-bottom: 12px;">
|
||||
${Object.keys(p).length>0?`<div style="margin-bottom: 12px;">
|
||||
<strong>Volumes:</strong>
|
||||
${Object.entries(g).map(([d,b])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(b)}</div>`).join("")}
|
||||
${Object.entries(p).map(([r,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(f)}</div>`).join("")}
|
||||
</div>`:""}
|
||||
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
Timezone: ${escapeHtml(H)} • IP: ${escapeHtml(r)} ${c?"• Tailscale only":""}
|
||||
Timezone: ${escapeHtml(u)} • IP: ${escapeHtml(s)} ${d?"• Tailscale only":""}
|
||||
</div>
|
||||
|
||||
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""}
|
||||
`}function C(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;u.forEach(g=>{g.checked&&m.add(g.dataset.componentId)});const h=A.components||[];return h.filter(g=>g.required).forEach(g=>m.add(g.id)),h.filter(g=>m.has(g.id))}async function N(){const u=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",u.innerHTML="";const h=C();for(const c of h){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
|
||||
${O.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(O.network.name)}</code></div>`:""}
|
||||
`}function M(){const y=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;y.forEach(p=>{p.checked&&m.add(p.dataset.componentId)});const b=O.components||[];return b.filter(p=>p.required).forEach(p=>m.add(p.id)),b.filter(p=>m.has(p.id))}async function D(){const y=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",y.innerHTML="";const b=M();for(const d of b){const r=document.createElement("div");r.id=`recipe-progress-${d.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
|
||||
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
||||
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
||||
<span style="flex:1; font-weight: 500;">${escapeHtml(d.role||d.id)}</span>
|
||||
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
||||
`,u.appendChild(d)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),H={};g.forEach(c=>{H[c.dataset.volumeKey]=c.value});const r={selectedComponents:h.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:H},componentOverrides:{}};for(const c of h)R(c.id,"deploying","Deploying...");try{const d=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:r})})).json();if(d.success){for(const b of d.deployed||[])R(b.id,"success",b.url?`Running \u2192 ${b.url}`:"Running");for(const b of d.errors||[])R(b.componentId,"error",b.error);m.style.display="",m.innerHTML=`
|
||||
`,y.appendChild(r)}const p=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};p.forEach(d=>{u[d.dataset.volumeKey]=d.value});const s={selectedComponents:b.map(d=>d.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:u},componentOverrides:{}};for(const d of b)x(d.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:O.id,config:s})})).json();if(r.success){for(const f of r.deployed||[])x(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of r.errors||[])x(f.componentId,"error",f.error);m.style.display="",m.innerHTML=`
|
||||
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(d.message||"Deployed!")}</div>
|
||||
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(r.message||"Deployed!")}</div>
|
||||
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<strong>Setup tips:</strong>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(b=>`<li>${escapeHtml(b)}</li>`).join("")}</ul>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul>
|
||||
</div>`:""}
|
||||
</div>
|
||||
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(d.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
||||
</div>`}}function R(u,m,h){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const H=g.querySelector(".recipe-progress-icon"),r=g.querySelector(".recipe-progress-status");m==="deploying"?(H.textContent="\u23F3",r.style.color="var(--accent)"):m==="success"?(H.textContent="\u2705",r.style.color="var(--ok-fg)"):m==="error"&&(H.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=h}v.addEventListener("click",()=>{if(E===3){E=4,T(),N();return}E<3&&(E++,T(),E===2&&L(),E===3&&j())}),D.addEventListener("click",()=>{E>1&&E<4&&(E--,T())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const m={};u.forEach(h=>{const g=h.dataset.recipeId;m[g]||(m[g]=[]),m[g].push(h)});for(const[h,g]of Object.entries(m))g.length<2||g.forEach((H,r)=>{if(H.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=H.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=h.replace(/-/g," "),H.style.position="relative",H.appendChild(c))}})},window.manageRecipe=async function(u,m){const h=`/api/v1/recipes/${u}/${m}`,g=m==="remove"?"DELETE":"POST",H=m==="remove"?`/api/v1/recipes/${u}`:h;if(!(m==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(H,{method:g})).json();c.success?(showNotification(`Recipe ${m}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const U=document.createElement("style");U.textContent=`
|
||||
`,showNotification(`${O.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(d){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(d.message)}
|
||||
</div>`}}function x(y,m,b){const p=document.getElementById(`recipe-progress-${y}`);if(!p)return;const u=p.querySelector(".recipe-progress-icon"),s=p.querySelector(".recipe-progress-status");m==="deploying"?(u.textContent="\u23F3",s.style.color="var(--accent)"):m==="success"?(u.textContent="\u2705",s.style.color="var(--ok-fg)"):m==="error"&&(u.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=b}g.addEventListener("click",()=>{if(E===3){E=4,B(),D();return}E<3&&(E++,B(),E===2&&C(),E===3&&j())}),P.addEventListener("click",()=>{E>1&&E<4&&(E--,B())}),window.groupRecipeCards=function(){const y=document.querySelectorAll(".service-card[data-recipe-id]");if(y.length===0)return;const m={};y.forEach(b=>{const p=b.dataset.recipeId;m[p]||(m[p]=[]),m[p].push(b)});for(const[b,p]of Object.entries(m))p.length<2||p.forEach((u,s)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let d=u.querySelector(".recipe-group-label");d||(d=document.createElement("div"),d.className="recipe-group-label",d.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",d.textContent=b.replace(/-/g," "),u.style.position="relative",u.appendChild(d))}})},window.manageRecipe=async function(y,m){const b=`/api/v1/recipes/${y}/${m}`,p=m==="remove"?"DELETE":"POST",u=m==="remove"?`/api/v1/recipes/${y}`:b;if(!(m==="remove"&&!confirm(`Remove the entire ${y} recipe? This will delete all containers and configuration.`)))try{const d=await(await secureFetch(u,{method:p})).json();d.success?(showNotification(`Recipe ${m}: ${d.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${d.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const A=document.createElement("style");A.textContent=`
|
||||
.recipe-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
.recipe-step-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
`,document.head.appendChild(U),x()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const f=document.getElementById("reload-caddy-top"),I=f.textContent;try{f.textContent="\u23F3 Reloading...",f.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await A.json();if(A.ok&&E.success)f.textContent="\u2705 Reloaded!",setTimeout(()=>{f.textContent=I,f.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(A){f.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{f.textContent=I,f.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const f=document.getElementById("error-log-modal"),I=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),M=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function z(){I.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const B=await(await fetch("/api/v1/error-logs")).json();B.success&&B.logs?B.logs.length===0?I.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':I.innerHTML=B.logs.map(x=>`
|
||||
`,document.head.appendChild(A),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),S=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const O=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await O.json();if(O.ok&&E.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(O){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${O.message}`,"error"),setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),S=document.getElementById("error-log-content"),O=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function z(){S.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?S.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':S.innerHTML=L.logs.map(w=>`
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${new Date(x.timestamp).toLocaleString()}</span>
|
||||
<span class="log-timestamp">${new Date(w.timestamp).toLocaleString()}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(x.context)}</strong>: ${escapeHtml(x.error)}
|
||||
${x.details?`<br><small style="opacity: 0.7;">${escapeHtml(x.details)}</small>`:""}
|
||||
<strong>${escapeHtml(w.context)}</strong>: ${escapeHtml(w.error)}
|
||||
${w.details?`<br><small style="opacity: 0.7;">${escapeHtml(w.details)}</small>`:""}
|
||||
</div>
|
||||
</div>
|
||||
`).join(""):I.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){I.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function D(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}A?.addEventListener("click",()=>{f.classList.add("show"),z()}),E?.addEventListener("click",z),M?.addEventListener("click",D),wireModal(f,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
`).join(""):S.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){S.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}O?.addEventListener("click",()=>{h.classList.add("show"),z()}),E?.addEventListener("click",z),N?.addEventListener("click",P),wireModal(h,R)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
|
||||
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
|
||||
<div>
|
||||
@@ -609,14 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("container-logs-modal"),I=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),M=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),B=document.getElementById("cl-close"),x=document.getElementById("cl-close-btn"),$=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),T=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),L=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),C=document.getElementById("cl-status"),N=document.getElementById("cl-created");let R=null,U=[],u=[],m=null,h=!1,g=null;function H(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function r(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const p=s.stream==="stderr"?"log-stderr":"log-stdout",w=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${p}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${w}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(s.text)}</span>
|
||||
</div>`);const h=document.getElementById("container-logs-modal"),S=document.getElementById("cl-container-select"),O=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),T=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),I=document.getElementById("cl-line-count"),C=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),M=document.getElementById("cl-status"),D=document.getElementById("cl-created");let x=null,A=[],y=[],m=null,b=!1,p=null;function u(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function s(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function d(i,c){const $=i.stream==="stderr"?"log-stderr":"log-stdout",H=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${$}" data-index="${c}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${c+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${i.stream==="stderr"?"#f59e0b":"#22c55e"};">${H}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(i.text)}</span>
|
||||
</div>
|
||||
`}function d(s,l=""){if(!s||s.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",L.textContent="0 filtered";return}if(U=s,u=l?s.filter(p=>p.text&&p.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,L.textContent=l?`${u.length} of ${s.length} shown`:`${s.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}A.innerHTML=u.map((p,w)=>c(p,w)).join(""),A.scrollTop=A.scrollHeight}async function b(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],p=I.value;I.innerHTML='<option value="">Select a container...</option>',l.forEach(w=>{const O=document.createElement("option");O.value=w.id,O.textContent=`${w.name} (${w.image.split(":")[0]}) - ${w.status}`,O.dataset.name=w.name,O.dataset.image=w.image,O.dataset.status=w.status,O.dataset.created=w.created,I.appendChild(O)}),p&&I.querySelector(`option[value="${p}"]`)&&(I.value=p,y(p))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=I.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",C.textContent=l.dataset.status||"-",C.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",N.textContent=H(l.dataset.created))}async function t(){const s=I.value;if(!s){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),R=s,y(s);const l=M.value,p=E.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const w=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,O=await getJSON(w);O.logs&&O.logs.length>0?d(O.logs,p):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",L.textContent="0 filtered")}catch(w){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(w.message)}</div>`}}function e(){const s=I.value;if(!s)return;a(),h=!0,z.textContent="\u23F9 Stop",$.style.display="flex",k.textContent="\u{1F7E2}",T.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;m=new EventSource(l),m.onopen=()=>{k.textContent="\u{1F7E2}",T.textContent="Connected - streaming logs"},m.onmessage=p=>{try{const w=JSON.parse(p.data);if(w.error){k.textContent="\u{1F534}",T.textContent=`Error: ${w.error}`;return}U.push(w),u.push(w),S.textContent=`${U.length} lines`,L.textContent=`${u.length} shown`;const O=E.value.trim();if(!O||w.text&&w.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(w,u.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(w){console.error("Error parsing log:",w)}},m.onerror=()=>{k.textContent="\u{1F534}",T.textContent="Disconnected",h=!1,z.textContent="\u25B6 Stream"},f._eventSource=m}function a(){m&&(m.close(),m=null),f._eventSource&&(f._eventSource.close(),f._eventSource=null),h=!1,z.textContent="\u25B6 Stream",$.style.display="none"}function o(){if(!U||U.length===0){showNotification("No logs to download","error");return}const s=I.querySelector(`option[value="${R}"]`)?.dataset.name||R,l=new Date().toISOString().replace(/[:.]/g,"-"),p=`${s}-logs-${l}.txt`,w=U.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
|
||||
`),O=new Blob([w],{type:"text/plain"}),_=URL.createObjectURL(O),F=document.createElement("a");F.href=_,F.download=p,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${U.length} log lines`,"success")}I?.addEventListener("change",()=>{t()}),M?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{h?a():e()}),D?.addEventListener("click",()=>{o()}),v?.addEventListener("click",()=>{E.value="",d(U,"")}),E?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{d(U,E.value.trim())},300)}),E?.addEventListener("keydown",s=>{s.key==="Escape"&&(E.value="",d(U,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{f.classList.add("show"),b()});function n(){a(),f.classList.remove("show")}B?.addEventListener("click",n),x?.addEventListener("click",n),document.addEventListener("keydown",s=>{s.key==="Escape"&&f.classList.contains("show")&&n()}),f.addEventListener("click",s=>{s.target===f&&n()}),window.openContainerLogsModal=function(s,l){f.classList.add("show"),b().then(()=>{const p=Array.from(I.options).find(w=>w.value===s||w.dataset.name===l);p?(I.value=p.value,y(p.value),t()):s?(R=s,j.textContent=l||s,C.textContent="-",N.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
`}function r(i,c=""){if(!i||i.length===0){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',I.textContent="0 lines",C.textContent="0 filtered";return}if(A=i,y=c?i.filter($=>$.text&&$.text.toLowerCase().includes(c.toLowerCase())):i,I.textContent=`${i.length} lines`,C.textContent=c?`${y.length} of ${i.length} shown`:`${i.length} shown`,y.length===0){O.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(c)}"</div>`;return}O.innerHTML=y.map(($,H)=>d($,H)).join(""),O.scrollTop=O.scrollHeight}async function f(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],$=S.value;S.innerHTML='<option value="">Select a container...</option>',c.forEach(H=>{const U=document.createElement("option");U.value=H.id,U.textContent=`${H.name} (${H.image.split(":")[0]}) - ${H.status}`,U.dataset.name=H.name,U.dataset.image=H.image,U.dataset.status=H.status,U.dataset.created=H.created,S.appendChild(U)}),$&&S.querySelector(`option[value="${$}"]`)&&(S.value=$,v($))}catch(i){console.error("Failed to load containers:",i)}}function v(i){const c=S.querySelector(`option[value="${i}"]`);c&&(j.textContent=c.dataset.image||"-",M.textContent=c.dataset.status||"-",M.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",D.textContent=u(c.dataset.created))}async function t(){const i=S.value;if(!i){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),x=i,v(i);const c=N.value,$=E.value.trim();O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const H=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,U=await getJSON(H);U.logs&&U.logs.length>0?r(U.logs,$):(O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',I.textContent="0 lines",C.textContent="0 filtered")}catch(H){O.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(H.message)}</div>`}}function e(){const i=S.value;if(!i)return;n(),b=!0,z.textContent="\u23F9 Stop",T.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;m=new EventSource(c),m.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},m.onmessage=$=>{try{const H=JSON.parse($.data);if(H.error){k.textContent="\u{1F534}",B.textContent=`Error: ${H.error}`;return}A.push(H),y.push(H),I.textContent=`${A.length} lines`,C.textContent=`${y.length} shown`;const U=E.value.trim();if(!U||H.text&&H.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=d(H,y.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",O.appendChild(F),O.scrollTop=O.scrollHeight}}catch(H){console.error("Error parsing log:",H)}},m.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",b=!1,z.textContent="\u25B6 Stream"},h._eventSource=m}function n(){m&&(m.close(),m=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),b=!1,z.textContent="\u25B6 Stream",T.style.display="none"}function a(){if(!A||A.length===0){showNotification("No logs to download","error");return}const i=S.querySelector(`option[value="${x}"]`)?.dataset.name||x,c=new Date().toISOString().replace(/[:.]/g,"-"),$=`${i}-logs-${c}.txt`,H=A.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
|
||||
`),U=new Blob([H],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=$,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${A.length} log lines`,"success")}S?.addEventListener("change",()=>{t()}),N?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{b?n():e()}),P?.addEventListener("click",()=>{a()}),g?.addEventListener("click",()=>{E.value="",r(A,"")}),E?.addEventListener("input",()=>{clearTimeout(p),p=setTimeout(()=>{r(A,E.value.trim())},300)}),E?.addEventListener("keydown",i=>{i.key==="Escape"&&(E.value="",r(A,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),f()});function o(){n(),h.classList.remove("show")}L?.addEventListener("click",o),w?.addEventListener("click",o),document.addEventListener("keydown",i=>{i.key==="Escape"&&h.classList.contains("show")&&o()}),h.addEventListener("click",i=>{i.target===h&&o()}),window.openContainerLogsModal=function(i,c){h.classList.add("show"),f().then(()=>{const $=Array.from(S.options).find(H=>H.value===i||H.dataset.name===c);$?(S.value=$.value,v($.value),t()):i?(x=i,j.textContent=c||i,M.textContent="-",D.textContent="-",t()):O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
||||
<h3>\u{1F4BE} Container Snapshots</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -670,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="snapshot-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("snapshot-modal"),I=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),M=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let D=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const L of S.containers){const j=document.createElement("option");j.value=L.id,j.textContent=`${L.name||L.id} (${L.image||"unknown"})`,j.dataset.name=L.name,j.dataset.image=L.image,j.dataset.status=L.status,j.dataset.created=L.created,E.appendChild(j)}}catch(T){console.error("Failed to load containers:",T)}}function B(T){if(!T||!T.value){M.style.display="none",D=null;return}D=T.value,document.getElementById("snapshot-image").textContent=T.dataset.image||"-",document.getElementById("snapshot-status").textContent=T.dataset.status||"-",document.getElementById("snapshot-created").textContent=T.dataset.created?new Date(T.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=T.value.substring(0,12),M.style.display=""}async function x(){if(!D){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const T=document.getElementById("snapshot-name").value.trim();if(!T){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${T}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(L){z.textContent=`\u2717 Error: ${L.message}`,z.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function $(){f.classList.add("show"),v()}function k(){f.classList.remove("show"),M.style.display="none",D=null,E.selectedIndex=0}I?.addEventListener("click",$),A?.addEventListener("click",k),wireModal(f,A),E?.addEventListener("change",T=>{const S=E.options[E.selectedIndex];B(S)}),P?.addEventListener("click",x),f?.querySelectorAll(".panel-tab").forEach(T=>{T.addEventListener("click",()=>{f.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),f.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),T.classList.add("active"),f.querySelector(`#${T.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
</div>`);const h=document.getElementById("snapshot-modal"),S=document.getElementById("snapshot-btn"),O=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let P=null;async function g(){try{const I=await(await fetch("/api/v1/containers")).json();if(!I.success||!I.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const C of I.containers){const j=document.createElement("option");j.value=C.id,j.textContent=`${C.name||C.id} (${C.image||"unknown"})`,j.dataset.name=C.name,j.dataset.image=C.image,j.dataset.status=C.status,j.dataset.created=C.created,E.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",P=null;return}P=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function w(){if(!P){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const I=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:I})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(C){z.textContent=`\u2717 Error: ${C.message}`,z.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function T(){h.classList.add("show"),g()}function k(){h.classList.remove("show"),N.style.display="none",P=null,E.selectedIndex=0}S?.addEventListener("click",T),O?.addEventListener("click",k),wireModal(h,O),E?.addEventListener("change",B=>{const I=E.options[E.selectedIndex];L(I)}),R?.addEventListener("click",w),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(I=>I.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(I=>I.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
||||
<h3>\u{1F3AC} Smart Arr Connect</h3>
|
||||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||||
@@ -749,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="arr-setup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("arr-setup-modal"),I=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),M=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),B=document.getElementById("smart-credential-inputs"),x=document.getElementById("smart-progress-steps"),$=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),T=document.getElementById("smart-retry-btn");let S=null;const L={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(g){M.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",z.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function N(g){const H={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},r=H[g]||H.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function R(){C("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let H='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(S.services)){const b=L[c]||"\u{1F4E6}",y=j[c]||c,t=d.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(d.source)}</span>`:"",e=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",a=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";H+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${b}</span>
|
||||
</div>`);const h=document.getElementById("arr-setup-modal"),S=document.getElementById("arr-setup-btn"),O=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),T=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=document.getElementById("smart-retry-btn");let I=null;const C={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(p){N.style.display=p==="detect"?"block":"none",R.style.display=p==="credentials"?"block":"none",z.style.display=p==="progress"?"block":"none",P.style.display=p==="results"?"block":"none"}function D(p){const u={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},s=u[p]||u.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function x(){M("detect"),g.style.display="none";try{if(I=await(await fetch("/api/v1/arr/smart-detect")).json(),!I.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(I.error)}</div>`,g.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[d,r]of Object.entries(I.services)){const f=C[d]||"\u{1F4E6}",v=j[d]||d,t=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",e=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",n=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${f}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div>
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${v}</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
${t} ${e} ${a}
|
||||
${t} ${e} ${n}
|
||||
</div>
|
||||
</div>
|
||||
${N(d.status)}
|
||||
</div>`}H+="</div>";const r=S.summary;H+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` · <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,v.innerHTML=H,v.style.display="block",U(S),setTimeout(()=>{C("credentials")},800)}catch(g){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,v.style.display="block"}}function U(g){let H="";const r=g.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const t=r[y];if(!t||t.status==="not_found"&&!t.url)continue;const e=L[y],a=j[y],o=t.status==="connected";H+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
|
||||
${D(r.status)}
|
||||
</div>`}u+="</div>";const s=I.summary;u+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` · <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,g.innerHTML=u,g.style.display="block",A(I),setTimeout(()=>{M("credentials")},800)}catch(p){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(p.message)}</div>`,g.style.display="block"}}function A(p){let u="";const s=p.services,d=["radarr","sonarr","prowlarr"];for(const v of d){const t=s[v];if(!t||t.status==="not_found"&&!t.url)continue;const e=C[v],n=j[v],a=t.status==="connected";u+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="font-size: 1.1rem;">${e}</span>
|
||||
<span style="font-weight: 500;">${a}</span>
|
||||
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${o?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
||||
<span style="font-weight: 500;">${n}</span>
|
||||
<span id="smart-${v}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${a?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
||||
<input type="text" id="smart-${y}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${y}/"
|
||||
<input type="text" id="smart-${v}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${v}/"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
||||
<input type="password" id="smart-${y}-key" placeholder="${o?"(saved)":"Paste API key"}"
|
||||
<input type="password" id="smart-${v}-key" placeholder="${a?"(saved)":"Paste API key"}"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="smartTestConnection('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const d=r.plex;if(d){const y=d.status==="connected";H+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
||||
<button onclick="smartTestConnection('${v}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const r=s.plex;if(r){const v=r.status==="connected";u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
|
||||
<span style="font-weight: 500;">Plex</span>
|
||||
${N(d.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
|
||||
${D(r.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
|
||||
</div>
|
||||
</div>`}const b=r.seerr;if(b){const y=b.status==="connected";let t="";if(b.configuredServices){const e=b.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
</div>`}const f=s.seerr;if(f){const v=f.status==="connected";let t="";if(f.configuredServices){const e=f.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
||||
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
||||
${e.plex?"✓ Plex":"✗ Plex"}
|
||||
</div>`}H+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};">
|
||||
</div>`}u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
||||
<span style="font-weight: 500;">Seerr</span>
|
||||
${N(b.status)}
|
||||
${D(f.status)}
|
||||
</div>
|
||||
${t}
|
||||
</div>`}B.innerHTML=H}window.smartTestConnection=async function(g){const H=document.getElementById(`smart-${g}-url`),r=document.getElementById(`smart-${g}-key`),c=document.getElementById(`smart-${g}-status`),d=H?.value.trim(),b=r?.value.trim();if(!d||!b){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:d,apiKey:b})})).json();t.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(t.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(y.message)}</span>`}};async function u(){C("progress"),x.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),d=document.getElementById(`smart-${r}-key`)?.value.trim();d&&c?g[r]={apiKey:d,url:c}:d&&(g[r]={apiKey:d})}const H={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(H)})).json();let d="";for(const b of c.steps||[]){const y=b.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',t=b.status==="success"?"var(--muted)":"var(--bad-fg)";d+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${y}
|
||||
<span>${escapeHtml(b.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(b.details||"")}</span>
|
||||
</div>`}x.innerHTML=d,setTimeout(()=>m(c),500)}catch(r){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function m(g){C("results");const H=g.summary||{},r=H.failed===0&&H.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",d=r?"✓":"⚠",b=r?"All Connected!":`${escapeHtml(String(H.succeeded))}/${escapeHtml(String(H.totalSteps))} Steps Succeeded`;let y=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${c};">${d}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${b}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(H.succeeded))} succeeded, ${escapeHtml(String(H.failed))} failed</div>
|
||||
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
</div>`}L.innerHTML=u}window.smartTestConnection=async function(p){const u=document.getElementById(`smart-${p}-url`),s=document.getElementById(`smart-${p}-key`),d=document.getElementById(`smart-${p}-status`),r=u?.value.trim(),f=s?.value.trim();if(!r||!f){d.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}d.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:p,url:r,apiKey:f})})).json();t.success?d.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:d.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(t.error)}</span>`}catch(v){d.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(v.message)}</span>`}};async function y(){M("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const p={};for(const s of["radarr","sonarr","prowlarr"]){const d=document.getElementById(`smart-${s}-url`)?.value.trim(),r=document.getElementById(`smart-${s}-key`)?.value.trim();r&&d?p[s]={apiKey:r,url:d}:r&&(p[s]={apiKey:r})}const u={services:Object.keys(p).length>0?p:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const d=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)})).json();let r="";for(const f of d.steps||[]){const v=f.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',t=f.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${v}
|
||||
<span>${escapeHtml(f.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(f.details||"")}</span>
|
||||
</div>`}w.innerHTML=r,setTimeout(()=>m(d),500)}catch(s){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function m(p){M("results");const u=p.summary||{},s=u.failed===0&&u.succeeded>0,d=s?"var(--ok-fg)":"#f39c12",r=s?"✓":"⚠",f=s?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.totalSteps))} Steps Succeeded`;let v=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${d} 12%, transparent); border-radius: 10px; border: 1px solid ${d}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${d};">${r}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${d};">${f}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
|
||||
</div>`;v+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of p.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';v+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
|
||||
</div>`}y+="</div>",$.innerHTML=y,T.style.display=H.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&h()}async function h(){try{const H=await(await fetch("/api/v1/plex/libraries")).json();if(H.success&&H.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(H.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of H.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${d} <strong>${escapeHtml(c.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
||||
</div>`}r+="</div></div>",k.innerHTML=r,k.style.display="block"}}catch{}}I?.addEventListener("click",()=>{f.classList.add("show"),k.style.display="none",R()}),wireModal(f,A),E?.addEventListener("click",u),T?.addEventListener("click",u)})(),(function(){const f=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
</div>`}v+="</div>",T.innerHTML=v,B.style.display=u.failed>0?"block":"none",p.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&b()}async function b(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(u.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const d of u.libraries){const r=d.type==="movie"?"\u{1F3AC}":d.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${r} <strong>${escapeHtml(d.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(d.count))} items</span>
|
||||
</div>`}s+="</div></div>",k.innerHTML=s,k.style.display="block"}}catch{}}S?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,O),E?.addEventListener("click",y),B?.addEventListener("click",y)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F514} Notification Settings</h3>
|
||||
|
||||
@@ -1000,15 +1000,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const I=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),M=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const T=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);T?.addEventListener("change",()=>{S.style.display=T.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");P?.addEventListener("change",()=>{z.style.opacity=P.checked?"1":"0.5"});async function D(){try{const T=await(await fetch("/api/v1/notifications/config")).json();if(T.success){const S=T.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){f.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const T=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");T.success&&T.history?.length>0?S.innerHTML=T.history.map(L=>{const j=new Date(L.timestamp).toLocaleString();return`
|
||||
</div>`);const S=document.getElementById("notifications-modal"),O=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),I=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{I.style.display=B.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");R?.addEventListener("change",()=>{z.style.opacity=R.checked?"1":"0.5"});async function P(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const I=B.config;document.getElementById("notifications-enabled").checked=I.enabled,document.getElementById("discord-enabled").checked=I.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=I.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=I.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=I.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=I.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=I.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=I.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=I.providers?.email?.enabled?"block":"none",I.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=I.providers.ntfy.serverUrl),I.providers?.email?.host&&(document.getElementById("email-host").value=I.providers.email.host),I.providers?.email?.from&&(document.getElementById("email-from").value=I.providers.email.from),document.getElementById("health-check-enabled").checked=I.healthCheck?.enabled||!1,I.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=I.healthCheck.intervalMinutes),I.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(I.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=I.events?.containerDown!==!1,document.getElementById("event-container-up").checked=I.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=I.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=I.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=I.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function g(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),I=document.getElementById("notification-history");B.success&&B.history?.length>0?I.innerHTML=B.history.map(C=>{const j=new Date(C.timestamp).toLocaleString();return`
|
||||
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[L.type]||"var(--muted)"}">${L.type==="success"?"\u2713":L.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[C.type]||"var(--muted)"}">${C.type==="success"?"\u2713":C.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(L.title)}</div>
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(C.title)}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){f.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function B(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),I.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function x(k){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();S.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>x("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>x("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>x("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>x("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const T=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();T.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(T.lastCheck).toLocaleString()} (${T.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{I.classList.add("show"),D(),v()}),E?.addEventListener("click",B),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),T=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const L=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();L.success?(showNotification("Test notification sent!","success",3e3),$()):showNotification(`Test failed: ${L.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=T,k.disabled=!1}});async function $(){try{const T=await(await fetch("/api/v1/notifications/status")).json();if(T.success&&T.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(T.lastSent).toLocaleString()}`)}}catch{}}wireModal(I,M)})(),(function(){document.addEventListener("click",f=>{const I=f.target.closest(".panel-tab");if(!I)return;const A=I.dataset.panel;if(!A)return;const E=I.closest(".panel-tabs"),M=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),I.classList.add("active"),M.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const P=M.querySelector("#"+A);P&&P.classList.add("active")})})(),(function(){var f=["dashcaddy_site_config","dashca
Line truncated
|
||||
`}).join(""):I.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},I=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();I.success?(showNotification("Notification settings saved","success",3e3),S.classList.remove("show")):showNotification(`Failed to save: ${I.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function w(k){try{const I=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();I.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${I.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),O?.addEventListener("click",()=>{S.classList.add("show"),P(),g()}),E?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const C=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();C.success?(showNotification("Test notification sent!","success",3e3),T()):showNotification(`Test failed: ${C.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function T(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const I=document.getElementById("last-notification-sent");I&&(I.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(S,N)})(),(function(){document.addEventListener("click",h=>{const S=h.target.closest(".panel-tab");if(!S)return;const O=S.dataset.panel;if(!O)return;const E=S.closest(".panel-tabs"),N=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),S.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const R=N.querySelector("#"+O);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca
Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F4BE} Backup & Restore</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1114,9 +1114,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="backup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);var P=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),B=document.getElementById("backup-select-file"),x=document.getElementById("backup-file-input"),$=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),T=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),L=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),C=document.getElementById("backup-history-container"),N=document.getElementById("backup-disk-container"),R=document.getElementById("pointintime-container"),U=null;z?.addEventListener("click",function(){P.classList.add("show"),L&&(L.style.display="none"),k&&(k.style.display="none"),$&&($.style.display="none"),U=null}),wireModal(P,D),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=I();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var s=Object.keys(a.browserState).length,l=a.themes?Object.keys(a.themes).length:0;L.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),L.style.display="block",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)"}catch(p){L.innerHTML="\u274C Export failed: "+escapeHtml(p.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),B?.addEventListener("click",function(){x.click()}),x?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){$.textContent="\u{1F4C4} "+a.name,$.style.display="block",L.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(E(i)){U=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",T.innerHTML=n,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await s.json();if(l.success){U=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var p in l.preview.files){var w=l.preview.files[p],O=w.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+O+" "+escapeHtml(w.description)+"</span>"}n+="</div>",l.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),T.innerHTML=n,k.style.display="block"}else L.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),L.style.display="block",L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12",k.style.display="none"}catch(_){L.innerHTML="\u274CLine truncated
|
||||
</div>`);var R=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),T=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),I=document.getElementById("backup-do-restore-btn"),C=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),M=document.getElementById("backup-history-container"),D=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),A=null;z?.addEventListener("click",function(){R.classList.add("show"),C&&(C.style.display="none"),k&&(k.style.display="none"),T&&(T.style.display="none"),A=null}),wireModal(R,P),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=S();var a=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),l=URL.createObjectURL(a),o=document.createElement("a");o.href=l,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(l);var i=Object.keys(n.browserState).length,c=n.themes?Object.keys(n.themes).length:0;C.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),C.style.display="block",C.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",C.style.border="1px solid var(--ok-fg)"}catch($){C.innerHTML="\u274C Export failed: "+escapeHtml($.message),C.style.display="block",C.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",C.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){T.textContent="\u{1F4C4} "+n.name,T.style.display="block",C.style.display="none";try{var a=await n.text(),l=JSON.parse(a);if(E(l)){A=l;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(l.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',l.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+l.services.length+" services</span>"),l.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+l.customApps.length+" custom apps</span>"),l.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(l.theme)+"</span>"),l.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(l.userThemes).length+" custom themes</span>"),o+="</div>",B.innerHTML=o,k.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}),c=await i.json();if(c.success){A=l;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(l.exportedAt).toLocaleString()+" (v"+escapeHtml(l.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var $ in c.preview.files){var H=c.preview.files[$],U=H.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(H.description)+"</span>"}o+="</div>",c.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),B.innerHTML=o,k.style.display="block"}else C.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),C.style.display="block",C.style.background="color-mix(in srgb, #f39c12 15%, transparent)",C.style.border="1px solid #f39c12",k.style.display="none"}catch(q){C.innerHTML="\u274CLine truncated
|
||||
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(n),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),l=await a.json();l.success?(showNotification(e+" restored to "+n,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(l.error||"Unknown"),"error")}catch(o){showNotification("Restore error: "+o.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F4CA} Resource Monitor</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1196,11 +1196,11 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="stats-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("stats-modal"),I=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),M=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let B=null,x=null;function $(d){if(d===0||!d)return"0 B";const b=1024,y=["B","KB","MB","GB"],t=Math.floor(Math.log(d)/Math.log(b));return parseFloat((d/Math.pow(b,t)).toFixed(1))+" "+y[t]}function k(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function T(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function S(){try{let d=null,b=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(d=e.stats,b=!0,x=e.stats)}catch{}if(!b){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){d={};for(const a of e.stats)d[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};x=d}}if(!d||Object.keys(d).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(d)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=k(o),s=T(i),l=a.memory?.usage||a.memory?.used||0,p=a.memory?.limit||0,w=a.network?.rxBytes||a.network?.rx||0,O=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;y+=`
|
||||
</div>`);const h=document.getElementById("stats-modal"),S=document.getElementById("container-stats-btn"),O=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let L=null,w=null;function T(r){if(r===0||!r)return"0 B";const f=1024,v=["B","KB","MB","GB"],t=Math.floor(Math.log(r)/Math.log(f));return parseFloat((r/Math.pow(f,t)).toFixed(1))+" "+v[t]}function k(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function B(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function I(){try{let r=null,f=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,f=!0,w=e.stats)}catch{}if(!f){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const n of e.stats)r[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};w=r}}if(!r||Object.keys(r).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let v='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(r)){const n=e.current||e,a=n.cpu?.percent||0,l=n.memory?.percent||0,o=k(a),i=B(l),c=n.memory?.usage||n.memory?.used||0,$=n.memory?.limit||0,H=n.network?.rxBytes||n.network?.rx||0,U=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;v+=`
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
|
||||
${_?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${_.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
||||
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
||||
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
||||
@@ -1208,32 +1208,32 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(o,100)}%; background: ${n}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${o.toFixed(1)}%</span>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(i,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
<div style="height: 100%; width: ${Math.min(l,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${l.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${$(l)} / ${$(p)}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${T(c)} / ${T($)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
||||
<div style="font-size: 0.8rem;">
|
||||
<span style="color: #3498db;">\u2193 ${$(w)}</span>
|
||||
<span style="color: #3498db;">\u2193 ${T(H)}</span>
|
||||
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
||||
<span style="color: #e74c3c;">\u2191 ${$(O)}</span>
|
||||
<span style="color: #e74c3c;">\u2191 ${T(U)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}y+="</div>",P.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function L(){if(!z)return;const d=x;if(!d||Object.keys(d).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let b='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,t]of Object.entries(d)){const e=t.aggregated;e&&(b+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||y}</div>
|
||||
</div>`}v+="</div>",R.innerHTML=v,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function C(){if(!z)return;const r=w;if(!r||Object.keys(r).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[v,t]of Object.entries(r)){const e=t.aggregated;e&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||v}</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
||||
@@ -1241,27 +1241,27 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
||||
</div>
|
||||
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
||||
</div>`)}b+="</div>",z.innerHTML=b}async function j(){if(!D)return;D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=x;if(!d||Object.keys(d).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let b=!1;try{b=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{b=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let t={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(t=s.configs||{})}catch{}const a=Object.entries(d).map(([n,s])=>{const l=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
||||
<tr data-container="${n}">
|
||||
<td style="font-weight: 600; padding: 8px;">${s.name||n}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${b?"":"disabled"} /></td>
|
||||
</div>`)}f+="</div>",z.innerHTML=f}async function j(){if(!P)return;P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=w;if(!r||Object.keys(r).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let v=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(v=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const n=Object.entries(r).map(([o,i])=>{const c=t[o]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
||||
<tr data-container="${o}">
|
||||
<td style="font-weight: 600; padding: 8px;">${i.name||o}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;">
|
||||
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${s.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
<button class="alert-test-btn btn-xs" data-container="${o}" data-name="${i.name||o}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join(""),o=y.map(n=>{const s=new Date(n.timestamp).toLocaleString(),l=n.notified?"\u2713":"\u2014";return`
|
||||
`}).join(""),a=v.map(o=>{const i=new Date(o.timestamp).toLocaleString(),c=o.notified?"\u2713":"\u2014";return`
|
||||
<tr>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${n.containerName||n.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${n.metric||n.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof n.value=="number"?n.value.toFixed(1):n.value}${n.metric==="disk"?" MB/s":"%"}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${l}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${i}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${o.containerName||o.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${o.metric||o.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof o.value=="number"?o.value.toFixed(1):o.value}${o.metric==="disk"?" MB/s":"%"}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${c}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${o.autoRestartTriggered?"#f39c12":"var(--muted)"};">${o.autoRestartTriggered?"\u21BB":""}</td>
|
||||
</tr>
|
||||
`}).join(""),i=b?`
|
||||
`}).join(""),l=f?`
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
||||
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
|
||||
@@ -1279,7 +1279,7 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<th style="padding: 6px 8px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${a}</tbody>
|
||||
<tbody>${n}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
|
||||
@@ -1292,11 +1292,11 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
|
||||
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
|
||||
</div>
|
||||
`;D.innerHTML=`
|
||||
${i}
|
||||
`;P.innerHTML=`
|
||||
${l}
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
|
||||
${o?`
|
||||
${a?`
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
|
||||
<thead>
|
||||
@@ -1309,26 +1309,26 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<th style="padding: 6px 8px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${o}</tbody>
|
||||
<tbody>${a}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
||||
</div>
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;n[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),p=document.getElementById("save-all-alerts");p.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{p.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),f.classList.remove("show"),N(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{f.classList.remove("show"),N(),typeof openLicenseModal=="function"&&openLicenseModal()})}function C(){B&&clearInterval(B),M?.checked&&(B=setInterval(S,DC.POLL.STATS))}function N(){B&&(clearInterval(B),B=null)}I?.addEventListener("click",()=>{f.classList.add("show"),S(),C()}),A?.addEventListener("click",()=>{f.classList.remove("show"),N()}),f?.addEventListener("click",d=>{d.target===f&&(f.classList.remove("show"),N())}),E?.addEventListener("click",S),M?.addEventListener("change",()=>{M.checked?C():N()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",L),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const R=document.getElementById("stats-history-container"),U=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let m="1h";function h(d){switch(d){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function H(d,b,y,t,e){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=d.map(b).filter(q=>q!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,s=600,l=80,p=4,w=(s-p*2)/Math.max(a.length-1,1),O=a.map((q,J)=>{const X=p+J*w,Q=l-p-(q-i)/n*(l-p*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],F=a.reduce((q,J)=>q+J,0)/a.length;return`
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const o={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;o[c]={cpuThreshold:parseInt(i.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(i.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(i.querySelector(".alert-disk")?.value)||50,autoRestart:!!i.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const c=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:o})})).json(),$=document.getElementById("save-all-alerts");$.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{$.textContent="Save All"},2e3)}catch{const c=document.getElementById("save-all-alerts");c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",o=>{o.preventDefault(),h.classList.remove("show"),D(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.textContent;o.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${o.dataset.container}/test`,{method:"POST"}),o.textContent="\u2705",showNotification("Test alert sent for "+o.dataset.name,"success",3e3)}catch{o.textContent="\u274C"}setTimeout(()=>{o.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),D(),typeof openLicenseModal=="function"&&openLicenseModal()})}function M(){L&&clearInterval(L),N?.checked&&(L=setInterval(I,DC.POLL.STATS))}function D(){L&&(clearInterval(L),L=null)}S?.addEventListener("click",()=>{h.classList.add("show"),I(),M()}),O?.addEventListener("click",()=>{h.classList.remove("show"),D()}),h?.addEventListener("click",r=>{r.target===h&&(h.classList.remove("show"),D())}),E?.addEventListener("click",I),N?.addEventListener("change",()=>{N.checked?M():D()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",C),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),A=document.getElementById("stats-history-container-area"),y=document.querySelectorAll(".stats-range-btn");let m="1h";function b(r){switch(r){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function p(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function u(r,f,v,t,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const n=r.map(f).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=Math.max(...n,1),l=Math.min(...n,0),o=a-l||1,i=600,c=80,$=4,H=(i-$*2)/Math.max(n.length-1,1),U=n.map((G,J)=>{const X=$+J*H,Q=c-$-(G-l)/o*(c-$*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],F=n.reduce((G,J)=>G+J,0)/n.length;return`
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
||||
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${o.toFixed(1)}${e}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${O}" />
|
||||
<svg viewBox="0 0 ${i} ${c}" preserveAspectRatio="none" style="width: 100%; height: ${c}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${v}" stroke-width="1.5" points="${U}" />
|
||||
</svg>
|
||||
</div>
|
||||
`}function r(){if(!R)return;const d=x||{},b=R.value,y=Object.entries(d);if(y.length===0){R.innerHTML='<option value="">No containers</option>';return}R.innerHTML=y.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),b&&d[b]&&(R.value=b)}async function c(){if(!U||!R)return;const d=R.value;if(!d){U.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const b=Date.now(),y=b-h(m);U.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${y}&endTime=${b}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?O=>O.cpu?.percent:O=>O.cpu?.avg,s=i?O=>O.memory?.percent:O=>O.memory?.avgPercent,l=i?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,p=i?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let w=`
|
||||
`}function s(){if(!x)return;const r=w||{},f=x.value,v=Object.entries(r);if(v.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=v.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),f&&r[f]&&(x.value=f)}async function d(){if(!A||!x)return;const r=x.value;if(!r){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),v=f-b(m);A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${v}&endTime=${f}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],a=e.tier||"raw";if(n.length===0){A.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${p(a)}.</div>`;return}const l=a==="raw",o=l?U=>U.cpu?.percent:U=>U.cpu?.avg,i=l?U=>U.memory?.percent:U=>U.memory?.avgPercent,c=l?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,$=l?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let H=`
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
||||
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date(b).toLocaleString()}
|
||||
${n.length} samples \xB7 ${escapeHtml(p(a))} \xB7 ${new Date(v).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
|
||||
</div>
|
||||
`;w+=H(a,n,"#2ecc71","CPU","%"),w+=H(a,s,"#3498db","Memory","%"),w+=H(a,l,"#9b59b6","Network RX"," MB"),w+=H(a,p,"#e67e22","Network TX"," MB"),U.innerHTML=w}catch(t){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}u.forEach(d=>{d.addEventListener("click",()=>{u.forEach(b=>b.classList.remove("active")),d.classList.add("active"),m=d.dataset.range,c()})}),R?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
`;H+=u(n,o,"#2ecc71","CPU","%"),H+=u(n,i,"#3498db","Memory","%"),H+=u(n,c,"#9b59b6","Network RX"," MB"),H+=u(n,$,"#e67e22","Network TX"," MB"),A.innerHTML=H}catch(t){A.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}y.forEach(r=>{r.addEventListener("click",()=>{y.forEach(f=>f.classList.remove("active")),r.classList.add("active"),m=r.dataset.range,d()})}),x?.addEventListener("change",d),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),d()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<h3>\u{1F3E5} Health Check Dashboard</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1361,44 +1361,6 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Settings: retention, polling intervals, max entries, 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;">\u{1F30D} 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; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Health Check Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-interval" value="60" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service's health endpoint is checked.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Stats Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-stats-interval" value="30" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often container statistics (CPU/memory) are sampled.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Max Entries Per Service</label>
|
||||
<input type="number" id="health-setting-max-entries" value="500" min="10" max="100000" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Cap on stored history records per service.</div>
|
||||
</div>
|
||||
<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">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>
|
||||
@@ -1452,24 +1414,24 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="health-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("health-modal"),I=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),M=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),B=document.getElementById("health-config-form"),x=document.getElementById("health-form-title"),$=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),T="dashcaddy-health-settings",S={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},L=document.getElementById("health-global-save"),j=document.getElementById("health-global-reset"),C=document.getElementById("health-global-status"),N=document.getElementById("health-setting-retention"),R=document.getElementById("health-setting-interval"),U=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),m=document.getElementById("health-setting-disk-threshold");function h(){try{const i=safeGet(T),n=i?JSON.parse(i):{};return Object.assign({},S,n)}catch{return Object.assign({},S)}}function g(){const i=h();N&&(N.value=i.retentionDays),R&&(R.value=i.pollingInterval),U&&(U.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),m&&(m.value=i.diskUsageThreshold)}function H(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(N?.value)||S.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||S.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(U?.value)||S.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||S.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(m?.value)||S.diskUsageThreshold))};try{safeSet(T,JSON.stringify(i)),g(),C&&(C.textContent="Saved \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){C&&(C.textContent="Save failed",C.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function r(){try{safeSet(T,JSON.stringify(S))}catch{}g(),C&&(C.textContent="Reset to defaults \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500))}g(),L?.addEventListener("click",H),j?.addEventListener("click",r);let c=null;function d(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function b(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function y(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const s=Object.values(n.status);let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',l+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',l+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',l+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of s){const w=p.status==="up",O=w?"var(--dot-ok)":"var(--dot-bad)",_=p.uptime?.["24h"]??"-",F=p.uptime?.["7d"]??"-",q=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",J=p.timestamp?timeAgo(p.timestamp):"-";l+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${w?"Up":"Down"}</td>`,l+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,l+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,l+=`<td style="padding: 8px;">${q}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,l+="</tr>",l+=`<tr id="health-detail-${escapeHtml(p.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
|
||||
</div>`);const h=document.getElementById("health-modal"),S=document.getElementById("health-check-btn"),O=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),T=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function I(y){return y>=99.9?"var(--ok-fg)":y>=95?"#f39c12":"var(--bad-fg)"}function C(y){const m={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${m[y]||"var(--muted)"}20; color: ${m[y]||"var(--muted)"};">${y}</span>`}async function j(){try{const m=await(await fetch("/api/v1/health-checks/status")).json();if(!m.success||!m.status||Object.keys(m.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const b=Object.values(m.status);let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',p+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',p+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',p+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const u of b){const s=u.status==="up",d=s?"var(--dot-ok)":"var(--dot-bad)",r=u.uptime?.["24h"]??"-",f=u.uptime?.["7d"]??"-",v=u.avgResponseTime!=null?Math.round(u.avgResponseTime)+"ms":"-",t=u.timestamp?timeAgo(u.timestamp):"-";p+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(u.serviceId)}">`,p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,p+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${d}; margin-right: 6px;"></span>${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px; color: ${typeof r=="number"?I(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,p+=`<td style="padding: 8px; color: ${typeof f=="number"?I(f):"var(--muted)"};">${typeof f=="number"?f.toFixed(1)+"%":f}</td>`,p+=`<td style="padding: 8px;">${v}</td>`,p+=`<td style="padding: 8px; color: var(--muted);">${t}</td>`,p+="</tr>",p+=`<tr id="health-detail-${escapeHtml(u.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}p+="</table>",N.innerHTML=p,P.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(u=>{u.addEventListener("click",async()=>{const s=u.dataset.healthId,d=document.getElementById("health-detail-"+s);if(d){if(d.style.display!=="none"){d.style.display="none";return}d.style.display="";try{const f=await(await fetch(`/api/v1/health-checks/${s}/stats?hours=24`)).json();if(f.success&&f.stats){const v=f.stats,t=v.responseTime||{};d.querySelector("td").innerHTML=`
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${d(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(J.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(J.p95||0)}ms / ${Math.round(J.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(J.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${q.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${q.downChecks||0}</strong></div>
|
||||
</div>`}else O.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){O.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),s=await i.json(),l=await n.json();let p="";const w=s.success&&s.incidents?s.incidents:[];if(w.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+w.length+")</h4>";for(const _ of w)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${v.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${I(v.uptime||0)};">${(v.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(t.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(t.p95||0)}ms / ${Math.round(t.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(t.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(t.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${v.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${v.downChecks||0}</strong></div>
|
||||
</div>`}else d.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){d.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(y.message)}</div>`}}async function M(){try{const[y,m]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),b=await y.json(),p=await m.json();let u="";const s=b.success&&b.incidents?b.incidents:[];if(s.length>0){u+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+s.length+")</h4>";for(const r of s)u+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
|
||||
<span>${b(_.severity)}</span>
|
||||
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span>
|
||||
<span>${C(r.severity)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
|
||||
</div>`;p+="</div>"}else p+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const O=l.success&&l.history?l.history:[];if(O.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of O){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,p+=`<td style="padding: 6px;">${b(_.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,p+=`<td style="padding: 6px;">${q}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,p+="</tr>"}p+="</table>"}P.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),s=n.success&&n.status?Object.values(n.status):[];if(s.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const p of s){const w=p.status==="up";l+='<tr style="border-bottom: 1px solid var(--border);">',l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px; color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"Up":"Down"}</td>`,l+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,l+="</td></tr>"}l+="</table>",z.innerHTML=l}catch(i){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,s,l,p,w,O){c=i||null,x.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=s||"",document.getElementById("health-form-timeout").value=l||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=w||99.9,document.getElementById("health-form-slow").value=O||5e3,B.style.display="",v.style.display="none"}function o(){B.style.display="none",v.style.display="",c=null}v?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),$?.addEventListener("click",o),k?.addEventListener("click",async()=>{const i=c||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const s=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),l={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:s.length?s:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
|
||||
</div>`;u+="</div>"}else u+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const d=p.success&&p.history?p.history:[];if(d.length>0){u+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',u+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of d){const f=r.status==="resolved",v=f&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,u+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,u+=`<td style="padding: 6px;">${C(r.severity)}</td>`,u+=`<td style="padding: 6px;"><span style="color: ${f?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,u+=`<td style="padding: 6px;">${v}</td>`,u+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,u+="</tr>"}u+="</table>"}R.innerHTML=u||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function D(){try{const m=await(await fetch("/api/v1/health-checks/status")).json(),b=m.success&&m.status?Object.values(m.status):[];if(b.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const u of b){const s=u.status==="up";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,p+=`<td style="padding: 8px; color: ${s?"var(--ok-fg)":"var(--bad-fg)"};">${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px;">${u.sla?.target?u.sla.target+"%":"-"}</td>`,p+='<td style="padding: 8px; text-align: right;">',p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,p+="</td></tr>"}p+="</table>",z.innerHTML=p}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}function x(y,m,b,p,u,s,d){B=y||null,w.textContent=y?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=y||"",document.getElementById("health-form-id").disabled=!!y,document.getElementById("health-form-name").value=m||"",document.getElementById("health-form-url").value=b||"",document.getElementById("health-form-timeout").value=p||1e4,document.getElementById("health-form-codes").value=u||"200",document.getElementById("health-form-sla").value=s||99.9,document.getElementById("health-form-slow").value=d||5e3,L.style.display="",g.style.display="none"}function A(){L.style.display="none",g.style.display="",B=null}g?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),T?.addEventListener("click",A),k?.addEventListener("click",async()=>{const y=B||document.getElementById("health-form-id").value.trim();if(!y)return showNotification("Service ID is required","warning");const m=document.getElementById("health-form-url").value.trim();if(!m)return showNotification("URL is required","warning");const b=document.getElementById("health-form-codes").value.split(",").map(u=>parseInt(u.trim())).filter(Boolean),p={name:document.getElementById("health-form-name").value.trim()||y,url:m,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:b.length?b:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u2B06\uFE0F Update Management</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1543,17 +1505,17 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="updates-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("updates-modal"),I=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),M=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function v(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const s=(()=>{const l=window.APPS||[];for(const p of l)if(p.containerId===n.containerId||p.name===n.containerName||p.id===n.containerName)return p.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",M.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,M.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(w.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(w.error||"Update failed")}catch(p){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+p.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),M.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(w.success)n.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(w.error||"Rollback failed")}catch(p){n.textContent="Failed",showNotification("Rollback error: "+p.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(y){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function B(){const y=window._pendingUpdates||[];if(!y.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml(p)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${O?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(p)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(p)}"${_?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(p)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",z.innerHTML=n,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,p=s.closest("tr"),w=p.querySelector(".auto-schedule").value,O=p.querySelector(".auto-rollback").checked,_=p.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!w,schedule:w||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)s.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const T=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),L=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),N=document.getElementById("dashcaddy-apply-btn"),R=document.getElementById("dashcaddy-check-btn"),U=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let h=null;function g(y,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=y)}async function H(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;T.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{T.textContent="Unable to fetch version"}}async function r(y){y||(R.textContent="Checking...",R.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(h=e,e.success&&e.available&&e.remote){S.style.display="",L.style.display="",j.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else S.style.display="none",L.style.display="none",await H(),y||g("You are running the latest version.","success");y||(R.textContent="Check for Updates",R.disabled=!1)}catch(t){y||(g("Failed to check: "+t.message,"error"),R.textContent="Check for Updates",R.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;N.textContent="Updating...",N.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),N.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(y){throw g("Update failed: "+y.message,"error"),N.textContent="Update Now",N.disabled=!1,y}}async function d(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
</div>`);const h=document.getElementById("updates-modal"),S=document.getElementById("updates-btn"),O=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function g(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const o of e){const i=(()=>{const c=window.APPS||[];for(const $ of c)if($.containerId===o.containerId||$.name===o.containerName||$.id===o.containerName)return $.id;return o.containerName})();n+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.containerName)}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(o.imageName)}</td>`,n+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(o.currentDigest)}</code></td>`,n+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(o.latestDigest)}</code></td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button class="update-now-btn" data-id="${escapeHtml(o.containerId)}" data-name="${escapeHtml(o.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,n+=`<button class="rollback-btn" data-id="${escapeHtml(o.containerId)}" data-name="${escapeHtml(o.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,n+="</td></tr>"}n+="</table>",N.innerHTML=n,P.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),l=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),l&&e.length>0&&(l.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.dataset.id,c=o.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){o.textContent="Updating...",o.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(H.success)o.textContent="Done!",o.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Update failed")}catch($){o.textContent="Failed",o.style.color="var(--bad-fg)",showNotification("Update error: "+$.message,"error"),setTimeout(()=>{o.textContent="Update",o.disabled=!1,o.style.color="",o.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.dataset.id,c=o.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){o.textContent="Rolling back...",o.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(H.success)o.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Rollback failed")}catch($){o.textContent="Failed",showNotification("Rollback error: "+$.message,"error"),setTimeout(()=>{o.textContent="Rollback",o.disabled=!1},3e3)}}})})}catch(v){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}async function L(){const v=window._pendingUpdates||[];if(!v.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${v.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,n=0;for(const a of v)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:n++}catch{n++}t.textCLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml($)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${U?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,o+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml($)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,o+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml($)}"${q?" checked":""} /></td>`,o+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,o+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml($)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,o+="</tr>"}o+="</table>",z.innerHTML=o,z.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,$=i.closest("tr"),H=$.querySelector(".auto-schedule").value,U=$.querySelector(".auto-rollback").checked,q=$.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!H,schedule:H||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(G.success)i.textContent="\u2713 Saved";else throw new Error(G.error)}catch(F){i.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(v){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),I=document.getElementById("dashcaddy-update-badge"),C=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),M=document.getElementById("dashcaddy-changelog"),D=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),A=document.getElementById("dashcaddy-rollback-btn"),y=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let b=null;function p(v,t){y&&(y.style.display="block",y.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",y.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",y.textContent=v)}async function u(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;B.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function s(v){v||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(b=e,e.success&&e.available&&e.remote){I.style.display="",C.style.display="",j.textContent="v"+e.remote.version,M.textContent=e.remote.changelog||"No changelog available.";const n=document.getElementById("updates-btn");if(n&&!n.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",n.style.position="relative",n.appendChild(l)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(l)}}else I.style.display="none",C.style.display="none",await u(),v||p("You are running the latest version.","success");v||(x.textContent="Check for Updates",x.disabled=!1)}catch(t){v||(p("Failed to check: "+t.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function d(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;D.textContent="Updating...",D.disabled=!0,p("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return p("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),D.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(v){throw p("Update failed: "+v.message,"error"),D.textContent="Update Now",D.disabled=!1,v}}async function r(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
`+e.join(`
|
||||
`)+`
|
||||
|
||||
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(y){g("Rollback failed: "+y.message,"error")}}R?.addEventListener("click",()=>r(!1)),N?.addEventListener("click",()=>c().catch(()=>{})),U?.addEventListener("click",b),E?.addEventListener("click",x),I?.addEventListener("click",()=>{f?.classList.add("show"),v()}),wireModal(f,A),window.openUpdateModal=function(y){f?.classList.add("show"),v().then(()=>{if(!y)return;const t=M.querySelector(`[data-app-id="${y}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",$),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{H(),d(),h||r(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=r,setTimeout(()=>r(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification("Invalid version: "+n,"error");return}if(!confirm("Rollback DashCaddy to v"+n+"? The container will restart."))return;p("Rolling back to v"+n+"...","info");const l=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:n})})).json();if(l.success)p("Rollback to v"+n+" initiated. Container will restart.","success");else throw new Error(l.error||"Rollback failed")}catch(v){p("Rollback failed: "+v.message,"error")}}x?.addEventListener("click",()=>s(!1)),D?.addEventListener("click",()=>d().catch(()=>{})),A?.addEventListener("click",f),E?.addEventListener("click",w),S?.addEventListener("click",()=>{h?.classList.add("show"),g()}),wireModal(h,O),window.openUpdateModal=function(v){h?.classList.add("show"),g().then(()=>{if(!v)return;const t=N.querySelector(`[data-app-id="${v}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",T),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{u(),r(),b||s(!0)}),window.dcApplyUpdate=d,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F433} Docker Resources</h3>
|
||||
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
||||
@@ -1602,7 +1564,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="dr-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("docker-resources-modal"),I=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function E(D){if(!D||D===0)return"0 B";const v=["B","KB","MB","GB","TB"],B=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,B)).toFixed(1)+" "+v[B]}async function M(){const D=document.getElementById("dr-vol-list");try{const B=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(B.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of B){const k=$.name==="buildkit"||$.name.length===64;x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml($.name)}">${escapeHtml($.name.length>40?$.name.substring(0,37)+"...":$.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-vol-del" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",D.innerHTML=x,D.querySelectorAll(".dr-vol-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete volume "${$.dataset.name}"? Data will be lost.`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent($.dataset.name)}?force=true`),M()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),v=D.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),D.value="",showNotification(`Volume "${v}" created`,"success"),M()}catch(B){showNotification("Create failed: "+B.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const B=(await getJSON("/api/v1/docker/networks")).networks||[];if(B.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of B){const k=["bridge","host","none"].includes($.name);x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml($.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+=`<td style="padding: 6px; text-align: center;">${$.containers}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-net-del" data-id="${escapeHtml($.id)}" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",D.innerHTML=x,D.querySelectorAll(".dr-net-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete network "${$.dataset.name}"?`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent($.dataset.id)}`),P()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),B=D.value.trim();if(!B){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:B,driver:v.value}),D.value="",showNotification(`Network "${B}" created`,"success"),P()}catch(x){showNotification("Create failed: "+x.message,"error")}});async fuLine truncated
|
||||
</div>`);const h=document.getElementById("docker-resources-modal"),S=document.getElementById("docker-resources-btn"),O=document.getElementById("dr-close");function E(P){if(!P||P===0)return"0 B";const g=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,L)).toFixed(1)+" "+g[L]}async function N(){const P=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const T of L){const k=T.name==="buildkit"||T.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(T.name)}">${escapeHtml(T.name.length>40?T.name.substring(0,37)+"...":T.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',k||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(T.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-vol-del").forEach(T=>{T.addEventListener("click",async()=>{if(confirm(`Delete volume "${T.dataset.name}"? Data will be lost.`)){T.textContent="...",T.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(T.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),T.textContent="Delete",T.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),g=P.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),P.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const T of L){const k=["bridge","host","none"].includes(T.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(T.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${T.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',k||(w+=`<button class="dr-net-del" data-id="${escapeHtml(T.id)}" data-name="${escapeHtml(T.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-net-del").forEach(T=>{T.addEventListener("click",async()=>{if(confirm(`Delete network "${T.dataset.name}"?`)){T.textContent="...",T.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(T.dataset.id)}`),R()}catch(k){showNotification("Delete failed: "+k.message,"error"),T.textContent="Delete",T.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),L=P.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:g.value}),P.value="",showNotification(`Network "${L}" created`,"success"),R()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
||||
<h3>\u{1F4E6} Import Docker Compose</h3>
|
||||
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
||||
@@ -1643,8 +1605,8 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<div id="compose-progress-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("compose-import-modal"),I=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(f,A);let E=null;function M(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}I?.addEventListener("click",()=>{M("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",f?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const D=z.target.files[0];if(!D)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),B=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const x=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:D});E=x,E.stackName=D,P(x),M("preview")}catch(x){showNotification("Parse failed: "+x.message,"error")}finally{v.textContent=B,v.disabled=!1}});function P(z){const D=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const B of z.services){const x=B.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${x}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(B.name)}`,B.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(B.reason)}</span>`),v+="</div>",!B.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(B.image)}</code></div>`,B.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${B.ports.map($=>`${$.host}:${$.container}`).join(", ")}</div>`),B.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${B.volumes.length}</div>`),Object.keys(B.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(B.environment).length}</div>`),B.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(B.envFileWarning)}</div>`),B.resources?.cpus||B.resources?.memory)){const $=[];B.resources.cpus&&$.push(`CPU: ${B.resources.cpus}`),B.resources.memory&&$.push(`Mem: ${B.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${$.join(", ")}</div>`}v+="</div>"}v+="</div>",D.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>M("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,M("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let B=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;B+='<div class="scroll-container" style="max-height: 350px;">';for(const x of v.results){const $=x.status==="deployed"||x.status==="created"?"\u2705":x.status==="exists"?"\u26A1":x.status==="skipped"?"\u23ED":"\u274C";B+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',B+=`${$} <strong>${escapeHtml(x.name)}</strong> (${x.type}) \u2014 ${escapeHtml(x.status)}`,x.error&&(B+=` <span style="color: var(--bad-fg);">${escapeHtml(x.error)}</span>`),x.subdomain&&(B+=` \u2192 <code>${escapeHtml(x.subdomain)}</code>`),x.reason&&(B+=` <span style="color: var(--muted);">(${escapeHtml(x.reason)})</span>`),B+="</div>"}B+="</div>",B+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>M("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
||||
</div>`);const h=document.getElementById("compose-import-modal"),S=document.getElementById("compose-import-btn"),O=document.getElementById("compose-cancel");wireModal(h,O);let E=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}S?.addEventListener("click",()=>{N("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const P=z.target.files[0];if(!P)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),L=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:P});E=w,E.stackName=P,R(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{g.textContent=L,g.disabled=!1}});function R(z){const P=document.getElementById("compose-preview-content");let g="";z.networks&&z.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const L of z.services){const w=L.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),g+="</div>",!L.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(T=>`${T.host}:${T.container}`).join(", ")}</div>`),L.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const T=[];L.resources.cpus&&T.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&T.push(`Mem: ${L.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${T.join(", ")}</div>`}g+="</div>"}g+="</div>",P.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const w of g.results){const T=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${T} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(L+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
||||
<h3 id="exec-title">Terminal</h3>
|
||||
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
||||
@@ -1652,11 +1614,11 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="exec-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("exec-modal"),I=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let E=null,M=null,P=null;function z(){if(M){try{M.close()}catch{}M=null}if(E){try{E.dispose()}catch{}E=null}P=null,I.innerHTML=""}function D(v,B){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${B||v}`,f?.classList.add("show"),typeof Terminal>"u"){I.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,E.loadAddon(P)),E.open(I),P&&setTimeout(()=>P.fit(),50);const x=location.protocol==="https:"?"wss:":"ws:";M=new WebSocket(`${x}//${location.host}/ws/exec/${encodeURIComponent(v)}`),M.binaryType="arraybuffer",M.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),P){const k=P.proposeDimensions();k&&M.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},M.onmessage=k=>{if(typeof k.data=="string"){try{const T=JSON.parse(k.data);if(T.type==="connected"){E.writeln(`\x1B[32mConnected (${T.shell})\x1B[0m\r
|
||||
`);return}if(T.type==="error"){E.writeln(`\x1B[31mError: ${T.message}\x1B[0m`);return}if(T.type==="exit"){E.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},M.onclose=()=>{E&&E.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},M.onerror=()=>{E&&E.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{M&&M.readyState===WebSocket.OPEN&&M.send(k)}),E.onResize(({cols:k,rows:T})=>{M&&M.readyState===WebSocket.OPEN&&M.send(JSON.stringify({type:"resize",cols:k,rows:T}))});const $=()=>{P&&P.fit()};window.addEventListener("resize",$),f._resizeHandler=$}A?.addEventListener("click",()=>{z(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show")}),f?.addEventListener("click",v=>{v.target===f&&(z(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
</div>`);const h=document.getElementById("exec-modal"),S=document.getElementById("exec-terminal"),O=document.getElementById("exec-close");let E=null,N=null,R=null;function z(){if(N){try{N.close()}catch{}N=null}if(E){try{E.dispose()}catch{}E=null}R=null,S.innerHTML=""}function P(g,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||g}`,h?.classList.add("show"),typeof Terminal>"u"){S.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,E.loadAddon(R)),E.open(S),R&&setTimeout(()=>R.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),R){const k=R.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){E.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r
|
||||
`);return}if(B.type==="error"){E.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){E.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},N.onclose=()=>{E&&E.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{E&&E.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),E.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const T=()=>{R&&R.fit()};window.addEventListener("resize",T),h._resizeHandler=T}O?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",g=>{g.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<h3>\u{1F4DC} Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1692,7 +1654,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="audit-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("audit-modal"),I=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),M=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),D=document.getElementById("audit-load-more");let v=0;const B=50;async function x($){try{$||(v=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=P.value;let T=`/api/v1/audit-logs?limit=${B}&offset=${v}`;k&&(T+=`&action=${encodeURIComponent(k)}`);const L=await(await fetch(T)).json(),j=L.success&&L.entries?L.entries:[];if(j.length===0&&!$){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',D.style.display="none";return}let C="";$||(C='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',C+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const N of j){const R=N.outcome==="success";C+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',C+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(N.timestamp)}</td>`,C+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(N.ip||"-")}</td>`,C+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(N.action||"-")}</td>`,C+=`<td style="padding: 6px;">${escapeHtml(N.resource||"-")}</td>`,C+=`<td style="padding: 6px;"><span style="color: ${R?"var(--ok-fg)":"var(--bad-fg)"};">${R?"\u2713":"\u2717"}</span></td>`,C+="</tr>",N.details&&Object.keys(N.details).length>0&&(C+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(N.details,null,2))}</pre></td></tr>`)}if(!$)C+="</table>",z.innerHTML=C;else{const N=z.querySelector("table");N&&N.insertAdjacentHTML("beforeend",C)}v+=j.length,D.style.display=j.length>=B?"":"none",z.querySelectorAll(".audit-row").forEach(N=>{N.dataset.wired||(N.dataset.wired="true",N.addEventListener("click",()=>{const R=N.nextElementSibling;R&&R.classList.contains("audit-detail")&&(R.style.display=R.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}I?.addEventListener("click",()=>{f?.classList.add("show"),x(!1)}),wireModal(f,A),E?.addEventListener("click",()=>x(!1)),P?.addEventListener("change",()=>x(!1)),D?.addEventListener("click",()=>x(!0)),M?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?x(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch($){showNotification("Error: "+$.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
|
||||
</div>`);const h=document.getElementById("audit-modal"),S=document.getElementById("audit-log-btn"),O=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let g=0;const L=50;async function w(T){try{T||(g=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=R.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${g}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const C=await(await fetch(B)).json(),j=C.success&&C.entries?C.entries:[];if(j.length===0&&!T){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',P.style.display="none";return}let M="";T||(M='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',M+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const D of j){const x=D.outcome==="success";M+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',M+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(D.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(D.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(D.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(D.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,M+="</tr>",D.details&&Object.keys(D.details).length>0&&(M+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(D.details,null,2))}</pre></td></tr>`)}if(!T)M+="</table>",z.innerHTML=M;else{const D=z.querySelector("table");D&&D.insertAdjacentHTML("beforeend",M)}g+=j.length,P.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(D=>{D.dataset.wired||(D.dataset.wired="true",D.addEventListener("click",()=>{const x=D.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}S?.addEventListener("click",()=>{h?.classList.add("show"),w(!1)}),wireModal(h,O),E?.addEventListener("click",()=>w(!1)),R?.addEventListener("change",()=>w(!1)),P?.addEventListener("click",()=>w(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?w(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(T){showNotification("Error: "+T.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
|
||||
<h3>\u{1F6E1}\uFE0F Security Center</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1771,39 +1733,39 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="sec-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("security-modal"),I=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),E=f.querySelectorAll(".sec-tab"),M=f.querySelectorAll(".sec-panel");let P=[],z=[],D=null;E.forEach(r=>{r.addEventListener("click",()=>{E.forEach(c=>c.classList.toggle("active",c===r)),M.forEach(c=>c.style.display=c.dataset.panel===r.dataset.tab?"":"none"),r.dataset.tab==="overview"&&$(),r.dataset.tab==="events"&&N(),r.dataset.tab==="hosts"&&m()})}),I&&I.addEventListener("click",()=>{f.classList.add("show"),$(),B()}),A.addEventListener("click",v),f.addEventListener("click",r=>{r.target===f&&v()});function v(){f.classList.remove("show"),x()}function B(){if(x(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",r=>{try{P=JSON.parse(r.data).events||[],R()}catch{}}),D.addEventListener("security",r=>{try{const c=JSON.parse(r.data);P.unshift(c),P.length>500&&(P.length=500);const d=f.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?R():d==="overview"&&$()}catch{}}),D.onerror=()=>{}}catch(r){console.warn("[security] SSE failed:",r.message)}}function x(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{f.classList.contains("show")&&B()});async function $(){try{const r=new Date(Date.now()-864e5).toISOString(),[c,d,b]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(r)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(r)}`)]),y=(await c.json()).data||{},t=(await d.json()).data?.hosts||[],e=(await b.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${y.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${y.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${y.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",y.top_actors||[]),k("sec-top-targets",y.top_targets||[])}catch(r){console.warn("[security] refreshOverview failed:",r.message)}}function k(r,c){const d=document.getElementById(r);if(!c.length){d.innerHTML='<div class="panel-empty">No data</div>';return}d.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(b=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(b.key))}</td><td style="text-align:right;color:var(--muted);">${b.count}</td></tr>`).join("")+"</table>"}const T=document.getElementById("sec-filter-source"),S=document.getElementById("sec-filter-severity"),L=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),C=document.getElementById("sec-refresh-btn");[T,S,L].forEach(r=>r.addEventListener("change",N)),j.addEventListener("input",H(N,250)),C.addEventListener("click",N);async function N(){try{const r=new URLSearchParams;r.set("limit","200"),T.value&&r.set("source_type",T.value),S.value&&r.set("severity",S.value),L.value&&r.set("source_host",L.value),j.value&&r.set("actor_prefix",j.value),P=(await(await fetch(`/api/v1/security/events?${r}`)).json()).data.events||[],R(),(!L.options.length||L.options.length===1)&&await u()}catch(r){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}function R(){const r=document.getElementById("sec-events-container");if(!P.length){r.innerHTML='<div class="panel-empty">No events</div>';return}r.innerHTML=P.slice(0,200).map(U).join("")}function U(r){const c=r.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",b=r.ts?new Date(r.ts).toLocaleTimeString():"",y=r.source_type||"",t=r.actor||"\u2014",e=r.target||"",a=r.action||"",o=r.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${d};font-weight:600;">${g(c)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${g(y)}</span>
|
||||
<span title="${g(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(t)}</span>
|
||||
<span title="${g(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(a)} ${g(e)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${g(o)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(b)}</span>
|
||||
</div>`}async function u(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=L.value;L.innerHTML='<option value="">All hosts</option>'+c.map(b=>`<option value="${g(b.id)}">${g(b.label||b.id)}</option>`).join(""),d&&(L.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",h),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=c;const d=document.getElementById("sec-hosts-container");if(!c.length){d.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}d.innerHTML=c.map(b=>{const y=b.enabled?b.last_seen_at?Date.now()-Date.parse(b.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
</div>`);const h=document.getElementById("security-modal"),S=document.getElementById("security-center-btn"),O=document.getElementById("sec-cancel"),E=h.querySelectorAll(".sec-tab"),N=h.querySelectorAll(".sec-panel");let R=[],z=[],P=null;E.forEach(s=>{s.addEventListener("click",()=>{E.forEach(d=>d.classList.toggle("active",d===s)),N.forEach(d=>d.style.display=d.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&T(),s.dataset.tab==="events"&&D(),s.dataset.tab==="hosts"&&m()})}),S&&S.addEventListener("click",()=>{h.classList.add("show"),T(),L()}),O.addEventListener("click",g),h.addEventListener("click",s=>{s.target===h&&g()});function g(){h.classList.remove("show"),w()}function L(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",s=>{try{R=JSON.parse(s.data).events||[],x()}catch{}}),P.addEventListener("security",s=>{try{const d=JSON.parse(s.data);R.unshift(d),R.length>500&&(R.length=500);const r=h.querySelector(".sec-tab.active")?.dataset?.tab;r==="events"?x():r==="overview"&&T()}catch{}}),P.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&L()});async function T(){try{const s=new Date(Date.now()-864e5).toISOString(),[d,r,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),v=(await d.json()).data||{},t=(await r.json()).data?.hosts||[],e=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${v.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${v.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${v.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",v.top_actors||[]),k("sec-top-targets",v.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function k(s,d){const r=document.getElementById(s);if(!d.length){r.innerHTML='<div class="panel-empty">No data</div>';return}r.innerHTML='<table style="width:100%;font-size:0.85rem;">'+d.map(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${p(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const B=document.getElementById("sec-filter-source"),I=document.getElementById("sec-filter-severity"),C=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),M=document.getElementById("sec-refresh-btn");[B,I,C].forEach(s=>s.addEventListener("change",D)),j.addEventListener("input",u(D,250)),M.addEventListener("click",D);async function D(){try{const s=new URLSearchParams;s.set("limit","200"),B.value&&s.set("source_type",B.value),I.value&&s.set("severity",I.value),C.value&&s.set("source_host",C.value),j.value&&s.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],x(),(!C.options.length||C.options.length===1)&&await y()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}function x(){const s=document.getElementById("sec-events-container");if(!R.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=R.slice(0,200).map(A).join("")}function A(s){const d=s.severity||"info",r={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[d]||"#7f8c8d",f=s.ts?new Date(s.ts).toLocaleTimeString():"",v=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",n=s.action||"",a=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${r};font-weight:600;">${p(d)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${p(v)}</span>
|
||||
<span title="${p(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(t)}</span>
|
||||
<span title="${p(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(n)} ${p(e)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${p(a)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${p(f)}</span>
|
||||
</div>`}async function y(){try{const d=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],r=C.value;C.innerHTML='<option value="">All hosts</option>'+d.map(f=>`<option value="${p(f.id)}">${p(f.label||f.id)}</option>`).join(""),r&&(C.value=r)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",b),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const d=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=d;const r=document.getElementById("sec-hosts-container");if(!d.length){r.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}r.innerHTML=d.map(f=>{const v=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${g(b.label||b.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(b.type)}</span>
|
||||
<strong>${p(f.label||f.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${p(f.type)}</span>
|
||||
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
||||
id: ${g(b.id)} \xB7
|
||||
registered ${new Date(b.registered_at).toLocaleDateString()} \xB7
|
||||
last seen ${b.last_seen_at?new Date(b.last_seen_at).toLocaleString():"never"}
|
||||
id: ${p(f.id)} \xB7
|
||||
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
|
||||
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:0.85rem;margin-right:12px;">${y}</span>
|
||||
${b.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(b.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
||||
<span style="font-size:0.85rem;margin-right:12px;">${v}</span>
|
||||
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${p(f.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
||||
</div>
|
||||
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(b=>{b.addEventListener("click",async()=>{confirm(`Remove host ${b.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(b.dataset.id)}`,{method:"DELETE"}),m())})})}catch(r){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}async function h(){const r=prompt("Host id (lowercase, no spaces):");if(!r)return;const c=prompt("Display label:",r)||r,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const b=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:r,label:c,type:d})}),y=await b.json();if(!b.ok){alert("Failed: "+(y?.error?.message||b.statusText));return}alert(`\u2705 Host registered!
|
||||
</div>`}).join(""),r.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),m())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}async function b(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const d=prompt("Display label:",s)||s,r=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:d,type:r})}),v=await f.json();if(!f.ok){alert("Failed: "+(v?.error?.message||f.statusText));return}alert(`\u2705 Host registered!
|
||||
|
||||
id: ${y.data.host.id}
|
||||
label: ${y.data.host.label}
|
||||
type: ${y.data.host.type}
|
||||
id: ${v.data.host.id}
|
||||
label: ${v.data.host.label}
|
||||
type: ${v.data.host.type}
|
||||
|
||||
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
|
||||
|
||||
${y.data.api_key}
|
||||
${v.data.api_key}
|
||||
|
||||
Send this key as: Authorization: Bearer <api_key>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b){alert("Failed: "+b.message)}}function g(r){return String(r).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c])}function H(r,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>r.apply(this,arguments),c)}}})(),(function(){const f=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f){alert("Failed: "+f.message)}}function p(s){return String(s).replace(/[&<>"']/g,d=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[d])}function u(s,d){let r;return function(){clearTimeout(r),r=setTimeout(()=>s.apply(this,arguments),d)}}})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
<label for="weather-location-input">Location:</label>
|
||||
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
||||
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
||||
@@ -1814,23 +1776,23 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b)
|
||||
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const I="weather-location",A="weather-zip",E="weather-geo",M="weather-unit";!safeGet(I)&&safeGet(A)&&safeSet(I,safeGet(A));function P(){return safeGet(M)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},B=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function x(C){return B[Math.round(C/22.5)%16]}async function $(C){const N=safeGet(E);if(N)try{const h=JSON.parse(N);if(h.query===C)return h}catch{}const R=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!R.ok)throw new Error("Geocoding failed");const U=await R.json();if(!U.results||!U.results.length)throw new Error("Location not found");const u=U.results[0],m={query:C,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(E,JSON.stringify(m)),m}function k(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function T(C){try{const N=await $(C),R=P(),U=R==="metric"?"celsius":"fahrenheit",u=R==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${N.lat}&longitude=${N.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${U}&wind_speed_unit=${u}`,h=await fetch(m);if(!h.ok)throw new Error("Weather fetch failed");const H=(await h.json()).current,r=H.weather_code;return{temp:Math.round(H.temperature_2m),condition:D[r]||"Unknown",icon:v[r]||"\u{1F324}\uFE0F",locationStr:k(N),windSpeed:Math.round(H.wind_speed_10m),windDir:x(H.wind_direction_10m),unit:R}}catch(N){return console.warn("Weather fetch failed:",N),null}}async function S(){const C=z();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const N=safeGet(I);if(!N){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const R=await T(N);if(R){const U=R.unit==="metric"?"\xB0C":"\xB0F",u=R.unit==="metric"?"km/h":"mph";C.location.textContent=R.locationStr,C.temp.textContent=`${R.temp}${U}`,C.condition.textContent=R.condition,C.wind.textContent=`Wind: ${R.windSpeed} ${u} ${R.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(R.icon)}</span>`}}catch(R){f.logError("[Weather] Update Error",R,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const L=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(I)||"";const C=P(),N=L.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);N&&(N.checked=!0),L.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=j.value.trim();if(C){safeGet(I)!==C&&safeSet(E,""),safeSet(I,C);const R=L.querySelector('input[name="weather-unit-radio"]:checked'),U=R?R.value:"imperial",u=P();safeSet(M,U),u!==U&&safeSet(E,""),L.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(L),document.addEventListener("keydown",C=>{C.keyLine truncated
|
||||
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const S="weather-location",O="weather-zip",E="weather-geo",N="weather-unit";!safeGet(S)&&safeGet(O)&&safeSet(S,safeGet(O));function R(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(M){return L[Math.round(M/22.5)%16]}async function T(M){const D=safeGet(E);if(D)try{const b=JSON.parse(D);if(b.query===M)return b}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(M)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const A=await x.json();if(!A.results||!A.results.length)throw new Error("Location not found");const y=A.results[0],m={query:M,lat:y.latitude,lon:y.longitude,city:y.name,state:y.admin1||"",country:y.country||"",countryCode:y.country_code||""};return safeSet(E,JSON.stringify(m)),m}function k(M){return M.countryCode==="US"&&M.state?`${M.city}, ${M.state}`:M.country?`${M.city}, ${M.country}`:M.city}async function B(M){try{const D=await T(M),x=R(),A=x==="metric"?"celsius":"fahrenheit",y=x==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${D.lat}&longitude=${D.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${A}&wind_speed_unit=${y}`,b=await fetch(m);if(!b.ok)throw new Error("Weather fetch failed");const u=(await b.json()).current,s=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:P[s]||"Unknown",icon:g[s]||"\u{1F324}\uFE0F",locationStr:k(D),windSpeed:Math.round(u.wind_speed_10m),windDir:w(u.wind_direction_10m),unit:x}}catch(D){return console.warn("Weather fetch failed:",D),null}}async function I(){const M=z();if(!M.icon||!M.temp||!M.condition||!M.location||!M.wind){console.warn("Weather widget elements not found");return}const D=safeGet(S);if(!D){M.location.textContent="Set Location",M.temp.textContent="--\xB0",M.condition.textContent="Click \u2699\uFE0F to configure",M.wind.textContent="--",M.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await B(D);if(x){const A=x.unit==="metric"?"\xB0C":"\xB0F",y=x.unit==="metric"?"km/h":"mph";M.location.textContent=x.locationStr,M.temp.textContent=`${x.temp}${A}`,M.condition.textContent=x.condition,M.wind.textContent=`Wind: ${x.windSpeed} ${y} ${x.windDir}`,M.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),M.location.textContent="Weather Error",M.temp.textContent="Error",M.condition.textContent="Failed to load",M.wind.textContent="--"}}const C=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(S)||"";const M=R(),D=C.querySelector(`input[name="weather-unit-radio"][value="${M}"]`);D&&(D.checked=!0),C.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const M=j.value.trim();if(M){safeGet(S)!==M&&safeSet(E,""),safeSet(S,M);const x=C.querySelector('input[name="weather-unit-radio"]:checked'),A=x?x.value:"imperial",y=R();safeSet(N,A),y!==A&&safeSet(E,""),C.classList.remove("show"),I()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(C),document.addEventListener("keydown",M=>{M.keyLine truncated
|
||||
<circle cx="${i}" cy="${c}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
${F}
|
||||
${_}
|
||||
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(O*Math.PI/180)}" y2="${l+28*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(w*Math.PI/180)}" y2="${l+38*Math.sin(w*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(p*Math.PI/180)}" y2="${l+42*Math.sin(p*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";I.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${T(t)}</span></div></div>`,B="analog"}function m(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(f.className!==i&&(f.className=i),P){case"lcd":C(t);break;case"lcd-blue":C(t);break;case"lcd-amber":C(t);break;case"lcd-retro":C(t);break;case"lcd-taxi":C(t);break;case"flip":N(t);break;case"binary":U(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:j(t)}a===0&&o===0&&e!==z&&(z=e,k(e)),a!==0&&(z=-1)}function h(){clearTimeout($);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;$=setTimeout(()=>{m(),h()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),m(),h()}),m(),h();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let H='<div class="clock-style-grid">';g.forEach(t=>{H+=`<label class="clock-style-option">
|
||||
${q}
|
||||
<line x1="${i}" y1="${c}" x2="${i+28*Math.cos(U*Math.PI/180)}" y2="${c+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${i}" y1="${c}" x2="${i+38*Math.cos(H*Math.PI/180)}" y2="${c+38*Math.sin(H*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${i}" y1="${c}" x2="${i+42*Math.cos($*Math.PI/180)}" y2="${c+42*Math.sin($*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${i}" cy="${c}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";S.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${B(t)}</span></div></div>`,L="analog"}function m(){const t=new Date,e=t.getHours()%12||12,n=t.getMinutes(),a=t.getSeconds(),l="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==l&&(h.className=l),R){case"lcd":M(t);break;case"lcd-blue":M(t);break;case"lcd-amber":M(t);break;case"lcd-retro":M(t);break;case"lcd-taxi":M(t);break;case"flip":D(t);break;case"binary":A(t);break;case"analog":y(t,!1);break;case"roman":y(t,!0);break;default:j(t)}n===0&&a===0&&e!==z&&(z=e,k(e)),n!==0&&(z=-1)}function b(){clearTimeout(T);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;T=setTimeout(()=>{m(),b()},e)}document.addEventListener("visibilitychange",()=>{g="",I(),m(),b()}),m(),b();const p=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let u='<div class="clock-style-grid">';p.forEach(t=>{u+=`<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${t.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
|
||||
</label>`}),H+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
</label>`}),u+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="max-width: 420px;">
|
||||
<h3>Clock Settings</h3>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
||||
${H}
|
||||
${u}
|
||||
</div>
|
||||
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
||||
@@ -1854,7 +1816,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b)
|
||||
<button id="clock-settings-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),b=document.getElementById("clock-volume-section");function y(){const t=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",b.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{b.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(d.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=r.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=e,v="",S(),m(),h(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,v="",S(),m()})})})(),(function(){async function f(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[v,B]of Object.entries(D.status)){const x=document.getElementById("uptime-"+v),$=document.getElementById("uptime-bar-"+v);if(!x)continue;const k=B.uptime?.["24h"];if(k!=null){const T=k.toFixed(1);x.textContent=`${T}% uptime`,x.className="uptime-chip",k>=99.9?x.classList.add("excellent"):k>=99?x.classList.add("good"):k>=95?x.classList.add("degraded"):x.classList.add("poor"),$&&($.style.width=T+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let I;try{I=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{I=new Set}let A=[];async function E(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(x=>x.classList.remove("visible"));const B=v.updates||[];if(A=B,z&&B.length>0){const x=window._lastKnownUpdateCount||0;x>0&&B.length>x&&showNotification(`${B.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=B.length}if(!B.length)return;for(const x of B){const $=window.APPS||[];for(const k of $)if(k.containerId===x.containerId||k.id===x.containerName||k.name===x.containerName){if(I.has(k.id))break;const T=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);T&&(T.classList.add("visible"),T.title="Update available \u2014 click to open Update Management.",T.style.cursor="pointer",T.onclick=L=>{L.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function M(){setTimeout(()=>{f(),E()},5e3),setInterval(()=>{f(),E(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(f,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),M()})(),(function(){var f=null,I=null,A={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},M=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var z=document.getElementById("theme-label");function D(o){if(E[o])return E[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function v(){z&&(z.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),s=o[(n+1)%o.length];window.applyTheme(s),v()}),v();function B(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
</div>`);const s=document.getElementById("clock-settings-modal"),d=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function v(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),d.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",f.style.opacity=d.checked?"1":"0.4"}d?.addEventListener("change",()=>{f.style.opacity=d.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{v(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(r.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(d.checked)),safeSet("clock-chime-volume",r.value),R=e,g="",I(),m(),b(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{R=t.value,g="",I(),m()})})})(),(function(){async function h(){try{const P=await(await fetch("/api/v1/health-checks/status")).json();if(!P.success||!P.status)return;for(const[g,L]of Object.entries(P.status)){const w=document.getElementById("uptime-"+g),T=document.getElementById("uptime-bar-"+g);if(!w)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);w.textContent=`${B}% uptime`,w.className="uptime-chip",k>=99.9?w.classList.add("excellent"):k>=99?w.classList.add("good"):k>=95?w.classList.add("degraded"):w.classList.add("poor"),T&&(T.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let S;try{S=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{S=new Set}let O=[];async function E(z){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const L=g.updates||[];if(O=L,z&&L.length>0){const w=window._lastKnownUpdateCount||0;w>0&&L.length>w&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const w of L){const T=window.APPS||[];for(const k of T)if(k.containerId===w.containerId||k.id===w.containerName||k.name===w.containerName){if(S.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),I=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.onclick=C=>{C.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),I&&(I.style.background="#f97316",I.style.borderColor="#f97316",I.style.boxShadow="0 0 6px #f9731688",I.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),E()},5e3),setInterval(()=>{h(),E(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,S=null,O={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var z=document.getElementById("theme-label");function P(a){if(E[a])return E[a];var l=safeGetJSON(window.USER_THEMES_KEY,{});return l[a]&&l[a].name||a}function g(){z&&(z.textContent=P(window.getActiveTheme()))}R.addEventListener("click",function(){var a=window.THEMES.slice(),l=window.getActiveTheme(),o=a.indexOf(l),i=a[(o+1)%a.length];window.applyTheme(i),g()}),g();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>DashCaddy License</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
@@ -1891,16 +1853,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b)
|
||||
<button id="license-activate" class="btn-accent">Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const f=document.getElementById("license-modal"),I=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),M=document.getElementById("license-error"),P=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),B=document.getElementById("license-details"),x=document.getElementById("license-feature-list"),$=document.getElementById("license-activate-section");let k=null;function T(){M.style.display="none",P.style.display="none"}function S(h){T(),M.textContent=h,M.style.display="block"}function L(h){T(),P.textContent=h,P.style.display="block"}function j(h){if(k=h,h.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",z.textContent="\u2605",D.textContent="Premium Active";const r=h.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(h.expiresAt).toLocaleDateString()}</strong> (${h.daysRemaining} days remaining)</div>`;B.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${h.code||"***"}</code></div>
|
||||
${r}
|
||||
`,$.style.display="none",A.style.display="none",E.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",z.textContent="\u2606",D.textContent=h.expired?"License Expired":"Free Tier",B.innerHTML=h.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",$.style.display="",A.style.display="",E.style.display="none";const g=h.premiumFeatures||{},H=new Set(h.features||[]);x.innerHTML=Object.entries(g).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${H.has(r)?"\u2705":"\u{1F512}"}</span>
|
||||
</div>`);const h=document.getElementById("license-modal"),S=document.getElementById("license-code-input"),O=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),R=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),P=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),L=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),T=document.getElementById("license-activate-section");let k=null;function B(){N.style.display="none",R.style.display="none"}function I(b){B(),N.textContent=b,N.style.display="block"}function C(b){B(),R.textContent=b,R.style.display="block"}function j(b){if(k=b,b.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",z.textContent="\u2605",P.textContent="Premium Active";const s=b.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(b.expiresAt).toLocaleDateString()}</strong> (${b.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${b.code||"***"}</code></div>
|
||||
${s}
|
||||
`,T.style.display="none",O.style.display="none",E.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",z.textContent="\u2606",P.textContent=b.expired?"License Expired":"Free Tier",L.innerHTML=b.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",T.style.display="",O.style.display="",E.style.display="none";const p=b.premiumFeatures||{},u=new Set(b.features||[]);w.innerHTML=Object.entries(p).map(([s,d])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${u.has(s)?"\u2705":"\u{1F512}"}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${d.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${d.description}</div>
|
||||
</div>
|
||||
</div>`).join("")}async function C(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(j(g.license),U(g.license))}catch(h){console.warn("Failed to load license status:",h.message)}}async function N(){const h=I.value.trim();if(!h){S("Please enter a license code.");return}T(),A.disabled=!0,A.textContent="Activating...";try{const H=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();H.success?(L(H.message),I.value="",j(H.license),showNotification("License activated! Premium features unlocked.","success",5e3),U(H.license)):S(H.error||"Activation failed")}catch(g){S("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function R(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(L(g.message),await C(),showNotification("License deactivated.","info",3e3),U({active:!1})):S(g.error||"Deactivation failed")}catch(h){S("Network error: "+h.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function U(h){const g=document.getElementById("license-status-topbar"),H=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(h.active?"premium":"free"),h.active)if(H.textContent="\u2605",r.textContent="PREMIUM",h.lifetime)c.textContent="\xB7 LIFETIME";else{const d=h.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else H.textContent="\u2606",r.textContent=h.expired?"EXPIRED":"FREE TIER",c.textContent=""}function u(){T(),C(),f.classList.add("show")}I.addEventListener("input",function(){let h=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(h.length>this._prevLength&&(h=h.replace(/-/g,""),h.length>2&&!h.startsWith("DC")&&(h="DC"+h),h.startsWith("DC")&&h.length>2)){const g=["DC"],H=h.substring(2);for(let r=0;r<H.length;r+=5)g.push(H.substring(r,r+5));h=g.join("-")}this._prevLength=h.length,this.value=h}),A.addEventListener("click",N),E.addEventListener("click",R),I.addEventListener("keydown",h=>{h.key==="Enter"&&N()}),wireModal(f,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(h){try{return(await(await fetch(`/api/v1/license/feature/${h}`)).json()).available}catch{return!1}},C().then(h=>{k&&U(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const f=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
|
||||
</div>`).join("")}async function M(){try{const p=await(await fetch("/api/v1/license/status")).json();p.success&&(j(p.license),A(p.license))}catch(b){console.warn("Failed to load license status:",b.message)}}async function D(){const b=S.value.trim();if(!b){I("Please enter a license code.");return}B(),O.disabled=!0,O.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();u.success?(C(u.message),S.value="",j(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),A(u.license)):I(u.error||"Activation failed")}catch(p){I("Network error: "+p.message)}finally{O.disabled=!1,O.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const p=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();p.success?(C(p.message),await M(),showNotification("License deactivated.","info",3e3),A({active:!1})):I(p.error||"Deactivation failed")}catch(b){I("Network error: "+b.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function A(b){const p=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),d=document.getElementById("license-topbar-time");if(p)if(p.className="license-status-topbar "+(b.active?"premium":"free"),b.active)if(u.textContent="\u2605",s.textContent="PREMIUM",b.lifetime)d.textContent="\xB7 LIFETIME";else{const r=b.daysRemaining;d.textContent=r!=null?"\xB7 "+r+"d remaining":""}else u.textContent="\u2606",s.textContent=b.expired?"EXPIRED":"FREE TIER",d.textContent=""}function y(){B(),M(),h.classList.add("show")}S.addEventListener("input",function(){let b=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(b.length>this._prevLength&&(b=b.replace(/-/g,""),b.length>2&&!b.startsWith("DC")&&(b="DC"+b),b.startsWith("DC")&&b.length>2)){const p=["DC"],u=b.substring(2);for(let s=0;s<u.length;s+=5)p.push(u.substring(s,s+5));b=p.join("-")}this._prevLength=b.length,this.value=b}),O.addEventListener("click",D),E.addEventListener("click",x),S.addEventListener("keydown",b=>{b.key==="Enter"&&D()}),wireModal(h,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=y,window.checkPremiumFeature=async function(b){try{return(await(await fetch(`/api/v1/license/feature/${b}`)).json()).available}catch{return!1}},M().then(b=>{k&&A(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const h=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
|
||||
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
|
||||
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
|
||||
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
|
||||
@@ -1922,7 +1884,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b)
|
||||
<div class="share-tab-panel" data-panel="public">
|
||||
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
|
||||
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
|
||||
${f.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
|
||||
${h.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
|
||||
</select>
|
||||
<button id="share-public-create" class="btn-accent" type="button"
|
||||
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
||||
@@ -1969,17 +1931,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);const I=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),M=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),z=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),v=document.getElementById("share-success"),B=document.getElementById("share-outstanding-list"),x=document.getElementById("share-cancel"),$=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),T=document.getElementById("share-ts-email"),S=document.getElementById("share-public-ttl");let L=null,j="public";function C(){D.style.display="none",v.style.display="none",E.style.display="none"}function N(t){v.style.display="none",E.style.display="none",D.textContent=t,D.style.display="block"}function R(t){D.style.display="none",v.textContent=t,v.style.display="block"}function U(){return window.location.origin}function u(t){return t.startsWith("http")?t:U()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function h(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){j=t,C(),I.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),I.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function H(){B.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){B.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===L.id);if(o.length===0){B.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}B.innerHTML=o.map(i=>{const n=i.expiresAt?h(new Date(i.expiresAt).getTime()-Date.now()):"";return`
|
||||
<div class="share-row" data-share-id="${escapeHtml(i.id)}"
|
||||
`);const S=document.getElementById("share-modal"),O=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),R=document.getElementById("share-issued-copy"),z=document.getElementById("share-issued-meta"),P=document.getElementById("share-error"),g=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),T=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),B=document.getElementById("share-ts-email"),I=document.getElementById("share-public-ttl");let C=null,j="public";function M(){P.style.display="none",g.style.display="none",E.style.display="none"}function D(t){g.style.display="none",E.style.display="none",P.textContent=t,P.style.display="block"}function x(t){P.style.display="none",g.textContent=t,g.style.display="block"}function A(){return window.location.origin}function y(t){return t.startsWith("http")?t:A()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function b(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const n=Math.floor(t%36e5/6e4);return`${e}h ${n}m left`}function p(t){j=t,M(),S.querySelectorAll(".share-tab").forEach(e=>{const n=e.dataset.tab===t;e.classList.toggle("active",n),e.style.borderBottomColor=n?"var(--accent)":"transparent",e.style.color=n?"var(--fg)":"var(--muted)",e.style.fontWeight=n?"600":"400"}),S.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function u(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),a=(e&&e.data||[]).filter(l=>l.serviceId===C.id);if(a.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=a.map(l=>{const o=l.expiresAt?b(new Date(l.expiresAt).getTime()-Date.now()):"";return`
|
||||
<div class="share-row" data-share-id="${escapeHtml(l.id)}"
|
||||
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
||||
<span style="flex: 1;">
|
||||
<strong>${i.kind==="tailscale"?"Tailscale":"Public"}</strong>
|
||||
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(i.expiresAt))} (${n})</span>
|
||||
<strong>${l.kind==="tailscale"?"Tailscale":"Public"}</strong>
|
||||
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(l.expiresAt))} (${o})</span>
|
||||
</span>
|
||||
<button class="share-revoke" data-share-id="${escapeHtml(i.id)}" type="button"
|
||||
<button class="share-revoke" data-share-id="${escapeHtml(l.id)}" type="button"
|
||||
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
`}).join(""),B.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>r(i.dataset.shareId))})}catch{B.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function r(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));N(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}R("Share revoked."),H()}catch{N("Network error while revoking.")}}async function c(){if(!L)return;C(),$.disabled=!0;const t=$.textContent;$.textContent="Creating\u2026";try{const e=parseInt(S.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:L.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){b(a.status,o);return}const i=o.data.urlPath;M.value=u(i),z.textContent=`Public link \xB7 expires ${m(o.data.expiresAt)}`,E.style.display="block",R("Share link created."),H()}catch{N("Network error while creating share.")}finally{$.disabled=!1,$.textContent=t}}async function d(){if(!L)return;const t=(T.value||"").trim();if(!t||!t.includes("@")){N("A valid recipient email is required.");return}C(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:L.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){b(a.status,o);return}const i=o.data&&o.data.urlPath;i?(M.value=u(i),z.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(M.value="",z.textContent=`Tailscale invite sent to ${t}.`),E.style.display="block",R("Tailscale invite created."),T.value="",H()}catch{N("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function b(t,e){const a=e&&e.error||"";if(t===402){N("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){N("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){N("You do not have permission to share services.");return}if(t===404){N("Service not found. It may have been deleted.");return}N(a||`Request failed (HTTP ${t}).`)}function y(t){!t||!t.id||(L={id:t.id,name:t.name||t.id},A.textContent=L.name,C(),g("public"),T.value="",S.value=String(f[1].ms),I.classList.add("show"),H())}I.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),$.addEventListener("click",c),k.addEventListener("click",d),x.addEventListener("click",()=>I.classList.remove("show")),wireModal(I,x),P.addEventListener("click",async()=>{const t=M.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(M.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{N("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=y})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc
Line truncated
|
||||
`}).join(""),L.querySelectorAll(".share-revoke").forEach(l=>{l.addEventListener("click",()=>s(l.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const n=await e.json().catch(()=>({}));D(n&&n.error||`Revoke failed (HTTP ${e.status}).`);return}x("Share revoked."),u()}catch{D("Network error while revoking.")}}async function d(){if(!C)return;M(),T.disabled=!0;const t=T.textContent;T.textContent="Creating\u2026";try{const e=parseInt(I.value,10),n=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,ttlMs:e})}),a=await n.json().catch(()=>({}));if(!n.ok||!a.success){f(n.status,a);return}const l=a.data.urlPath;N.value=y(l),z.textContent=`Public link \xB7 expires ${m(a.data.expiresAt)}`,E.style.display="block",x("Share link created."),u()}catch{D("Network error while creating share.")}finally{T.disabled=!1,T.textContent=t}}async function r(){if(!C)return;const t=(B.value||"").trim();if(!t||!t.includes("@")){D("A valid recipient email is required.");return}M(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const n=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,email:t})}),a=await n.json().catch(()=>({}));if(!n.ok||!a.success){f(n.status,a);return}const l=a.data&&a.data.urlPath;l?(N.value=y(l),z.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(N.value="",z.textContent=`Tailscale invite sent to ${t}.`),E.style.display="block",x("Tailscale invite created."),B.value="",u()}catch{D("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function f(t,e){const n=e&&e.error||"";if(t===402){D("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(n)){D("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){D("You do not have permission to share services.");return}if(t===404){D("Service not found. It may have been deleted.");return}D(n||`Request failed (HTTP ${t}).`)}function v(t){!t||!t.id||(C={id:t.id,name:t.name||t.id},O.textContent=C.name,M(),p("public"),B.value="",I.value=String(h[1].ms),S.classList.add("show"),u())}S.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>p(t.dataset.tab))}),T.addEventListener("click",d),k.addEventListener("click",r),w.addEventListener("click",()=>S.classList.remove("show")),wireModal(S,w),R.addEventListener("click",async()=>{const t=N.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(N.select(),document.execCommand("copy"));const e=R.textContent;R.textContent="Copied!",setTimeout(()=>{R.textContent=e},1200)}catch{D("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=v})();
|
||||
@@ -329,4 +329,3 @@
|
||||
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKHdpbmRvdy5yZWZyZXNoQWxsLCBEQy5QT0xMLkRBU0hCT0FSRCk7XG4gICAgaWYgKHR5cGVvZiB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hDcmVkc0J1dHRvbnMoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMgPT09ICdmdW5jdGlvbicpIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMoKTtcbiAgICAvLyBVcGRhdGUgYXV0aCBjYXJkIChtYXkgaGF2ZSBhbHJlYWR5IGJlZW4gdXBkYXRlZCBieSB0aGUgYXV0by1sb2FkIElJRkUgYnV0IGVuc3VyZSBpdCdzIGNvcnJlY3QpXG4gICAgaWYgKHR5cGVvZiB3aW5kb3cuX3VwZGF0ZUF1dGhDYXJkID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBjb25zdCByID0gYXdhaXQgZmV0Y2goJy9hcGkvdjEvdG90cC9jb25maWcnLCB7IGNhY2hlOiAnbm8tc3RvcmUnIH0pO1xuICAgICAgICBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7XG4gICAgICAgIGlmIChkLnN1Y2Nlc3MpIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQoZC5jb25maWcuZW5hYmxlZCAmJiBkLmNvbmZpZy5pc1NldFVwLCBkLmNvbmZpZy5zZXNzaW9uRHVyYXRpb24pO1xuICAgICAgfSBjYXRjaCAoZSkgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICBpZiAod2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZCkge1xuICAgICAgdHJ5IHtcbiAgICAgICAgYXdhaXQgd2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZDtcbiAgICAgIH0gY2F0Y2ggKF8pIHsgLyogaWdub3JlICovIH1cbiAgICB9XG4gICAgLy8gTGF6eS1sb2FkIG9uYm9hcmRpbmcgb25seSBvbmNlIHBlciBpbnN0YWxsLCBvdGhlcndpc2UganVzdCBhZGQgdGhlIHRvdXIgYnV0dG9uXG4gICAgYWRkVG91ckJ1dHRvbigpO1xuICAgIGlmIChzaG91bGRMb2FkT25ib2FyZGluZygpKSB7XG4gICAgICBsb2FkT25ib2FyZGluZygpO1xuICAgIH1cblxuICAgIC8vIERDLTA0ODogaW5qZWN0IHRoZSBcIkFkbWluXCIgdHJpZ2dlciBidXR0b24gaW50byB0aGUgdG9wIGJhci4gVGhlXG4gICAgLy8gYnV0dG9uIG9ubHkgcmVuZGVycyB3aGVuIC9tZSByZXR1cm5zIGlzQWRtaW49dHJ1ZTsgdGhlIG1vZHVsZVxuICAgIC8vIHJlLWNoZWNrcyBldmVyeSA2MHMgc28gYSBwZXJtaXNzaW9uIGRvd25ncmFkZSB0YWtlcyBlZmZlY3QuXG4gICAgaWYgKHdpbmRvdy5BZG1pblBhbmVsICYmIHR5cGVvZiB3aW5kb3cuQWRtaW5QYW5lbC5hdHRhY2hUcmlnZ2VyID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuQWRtaW5QYW5lbC5hdHRhY2hUcmlnZ2VyKGRvY3VtZW50LmJvZHkpO1xuICAgICAgfSBjYXRjaCAoZ
Line truncated
|
||||
@@ -198,4 +198,3 @@ ${b}`)}}y.TooltipValidation={validateTooltipDefinition:a,validateTooltipDefiniti
|
||||
Possible issues:
|
||||
- Driver.js library failed to load
|
||||
- JavaScript errors during initialization`))},l=document.getElementById("restart-tour-btn");if(l){l.onclick=C;return}const u=document.createElement("button");u.id="restart-tour-btn",u.textContent="Help Tour",u.title="Restart the onboarding tour",u.onclick=C,m.appendChild(u)}function A(){return typeof(window.driver?.js?.driver||window.driver?.driver||window.driver)!="function"?(console.warn("[Onboarding] Driver.js not loaded yet, will retry... window.driver:",window.driver),!1):!0}function k(){let m=0;const C=10;function l(){A()?n():(m++,m<C?setTimeout(l,500):r?r.handleDriverLoadFailure():new ErrorHandler().handleDriverLoadFailure())}l()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",k):k(),y("[Onboarding] System loaded")})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsidGhpcy5kcml2ZXI9dGhpcy5kcml2ZXJ8fHt9O3RoaXMuZHJpdmVyLmpzPWZ1bmN0aW9uKEQpe1widXNlIHN0cmljdFwiO2xldCBGPXt9O2Z1bmN0aW9uIHooZT17fSl7Rj17YW5pbWF0ZTohMCxhbGxvd0Nsb3NlOiEwLG92ZXJsYXlPcGFjaXR5Oi43LHNtb290aFNjcm9sbDohMSxkaXNhYmxlQWN0aXZlSW50ZXJhY3Rpb246ITEsc2hvd1Byb2dyZXNzOiExLHN0YWdlUGFkZGluZzoxMCxzdGFnZVJhZGl1czo1LHBvcG92ZXJPZmZzZXQ6MTAsc2hvd0J1dHRvbnM6W1wibmV4dFwiLFwicHJldmlvdXNcIixcImNsb3NlXCJdLGRpc2FibGVCdXR0b25zOltdLG92ZXJsYXlDb2xvcjpcIiMwMDBcIiwuLi5lfX1mdW5jdGlvbiBhKGUpe3JldHVybiBlP0ZbZV06Rn1mdW5jdGlvbiBXKGUsbyx0LGkpe3JldHVybihlLz1pLzIpPDE/dC8yKmUqZStvOi10LzIqKC0tZSooZS0yKS0xKStvfWZ1bmN0aW9uIHEoZSl7Y29uc3Qgbz0nYVtocmVmXTpub3QoW2Rpc2FibGVkXSksIGJ1dHRvbjpub3QoW2Rpc2FibGVkXSksIHRleHRhcmVhOm5vdChbZGlzYWJsZWRdKSwgaW5wdXRbdHlwZT1cInRleHRcIl06bm90KFtkaXNhYmxlZF0pLCBpbnB1dFt0eXBlPVwicmFkaW9cIl06bm90KFtkaXNhYmxlZF0pLCBpbnB1dFt0eXBlPVwiY2hlY2tib3hcIl06bm90KFtkaXNhYmxlZF0pLCBzZWxlY3Q6bm90KFtkaXNhYmxlZF0pJztyZXR1cm4gZS5mbGF0TWFwKHQ9Pntjb25zdCBpPXQubWF0Y2hlcyhvKSxwPUFycmF5LmZyb20odC5xdWVyeVNlbGVjdG9yQWxsKG8pKTtyZXR1cm5bLi4uaT9bdF06W10sLi4ucF19KS5maWx0ZXIodD0+Z2V0Q29tcHV0ZWRTdHlsZSh0KS5wb2ludGVyRXZlbnRzIT09XCJub25lXCImJmFlKHQpKX1mdW5jdGlvbiBWKGUpe2lmKCFlfHxzZShlKSlyZXR1cm47Y29uc3Qgbz1hKFwic21vb3RoU2Nyb2xsXCIpO2Uuc2Nyb2xsSW50b1ZpZXcoe2JlaGF2aW9yOiFvfHxyZShlKT9cImF1dG9cIjpcInNtb290aFwiLGlubGluZTpcImNlbnRlclwiLGJsb2NrOlwiY2VudGVyXCJ9KX1mdW5jdGlvbiByZShlKXtpZighZXx8IWUucGFyZW50RWxlbWVudClyZXR1cm47Y29uc3Qgbz1lLnBhcmVudEVsZW1lbnQ7cmV0dXJuIG8uc2Nyb2xsSGVpZ2h0Pm8uY2xpZW50SGVpZ2h0fWZ1bmN0aW9uIHNlKGUpe2NvbnN0IG89ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKTtyZXR1cm4gby50b3A+PTAmJm8ubGVmdD49MCYmby5ib3R0b208PSh3aW5kb3cuaW5uZXJIZWlnaHR8fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRIZWlnaHQpJiZvLnJpZ2h0PD0od2luZG93LmlubmVyV2lkdGh8fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRXaWR0aCl9ZnVuY3Rpb24gYWUoZSl7cmV0dXJuISEoZS5vZmZzZXRXaWR0aHx8ZS5vZmZzZXRIZWlnaHR8fGUuZ2V0Q2xpZW50UmVjdHMoKS5sZW5ndGgpfWxldCBOPXt9O2Z1bmN0aW9uIGIoZSxvKXtOW2VdPW99ZnVuY3Rpb24gbChlKXtyZXR1cm4gZT9OW2VdOk59ZnVuY3Rpb24gSygpe049e319bGV0IEU9e307ZnVuY3Rpb24gTyhlLG8pe0VbZV09b31mdW5jdGlvbiBfKGUpe3ZhciBvOyhvPUVbZV0pPT1udWxsfHxvLmNhbGwoRSl9ZnVuY3Rpb24gY2UoKXtFPXt9fWZ1bmN0aW9uIGxlKGUsbyx0LGkpe2xldCBwPWwoXCJfX2FjdGl2ZVN0YWdlUG9zaXRpb25cIik7Y29uc3Qgbj1wfHx0LmdldEJvdW5kaW5nQ2xpZW50UmVjdCgpLGY9aS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKSx3PVcoZSxuLngsZi54LW4ueCxvKSxyPVcoZSxuLnksZi55LW4ueSxvKSx2PVcoZSxuLndpZHRoLGYud2lkdGgtbi53aWR0aCxvKSxzPVcoZSxuLmhlaWdodCxmLmhlaWdodC1uLmhlaWdodCxvKTtwPXt4OncseTpyLHdpZHRoOnYsaGVpZ2h0OnN9LFkocCksYihcIl9fYWN0aXZlU3RhZ2VQb3NpdGlvblwiLHApfWZ1bmN0aW9uIFgoZSl7aWYoIWUpcmV0dXJuO2NvbnN0IG89ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKSx0PXt4Om8ueCx5Om8ueSx3aWR0aDpvLndpZHRoLGhlaWdodDpvLmhlaWdodH07YihcIl9fYWN0aXZlU3RhZ2VQb3NpdGlvblwiLHQpLFkodCl9ZnVuY3Rpb24gZGUoKXtjb25zdCBlPWwoXCJfX2FjdGl2ZVN0YWdlUG9zaXRpb25cIiksbz1sKFwiX19vdmVybGF5U3ZnXCIpO2lmKCFlKXJldHVybjtpZighbyl7Y29uc29sZS53YXJuKFwiTm8gc3RhZ2Ugc3ZnIGZvdW5kLlwiKTtyZXR1cm59Y29uc3QgdD13aW5kb3cuaW5uZXJXaWR0aCxpPXdpbmRvdy5pbm5lckhlaWdodDtvLnNldEF0dHJpYnV0ZShcInZpZXdCb3hcIixgMCAwICR7dH0gJHtpfWApfWZ1bmN0aW9uIHBlKGUpe2NvbnN0IG89dWUoZSk7ZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChvKSxHKG8sdD0+e3QudGFyZ2V0LnRhZ05hbWU9PT1cInBhdGhcIiYmXyhcIm92ZXJsYXlDbGlja1wiKX0pLGIoXCJfX292ZXJsYXlTdmdcIixvKX1mdW5jdGlvbiBZKGUpe2NvbnN0IG89bChcIl9fb3ZlcmxheVN2Z1wiKTtpZighbyl7cGUoZSk7cmV0dXJufWNvbnN0IHQ9by5maXJzdEVsZW1lbnRDaGlsZDtpZigodD09bnVsbD92b2lkIDA6dC50YWdOYW1lKSE9PVwicGF0aFwiKXRocm93IG5ldyBFcnJvcihcIm5vIHBhdGggZWxlbWVudCBmb3VuZCBpbiBzdGFnZSBzdmdcIik7dC5zZXRBdHRyaWJ1dGUoXCJkXCIsaihlKSl9ZnVuY3Rpb24gdWUoZSl7Y29uc3Qgbz13aW5kb3cuaW5uZXJXaWR0aCx0PXdpbmRvdy5pbm5lckhlaWdodCxpPWRvY3VtZW50LmNyZWF0ZUVsZW1lbnROUyhcImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIsXCJzdmdcIik7aS5jbGFzc0xpc3QuYWRkKFwiZHJpdmVyLW92ZXJsYXlcIixcImRyaXZlci1vdmVybGF5LWFuaW1hdGVkXCIpLGkuc2V0QXR0cmlidXRlKFwidmlld0JveFwiLGAwIDAgJHtvfSAke3R9YCksaS5zZXRBdHRyaWJ1dGUoXCJ4bWxTcGFjZVwiLFwicHJlc2VydmVcIiksaS5zZXRBdHRyaWJ1dGUoXCJ4bWxuc1hsaW5rXCIsXCJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rXCIpLGkuc2V0QXR0cmlidXRlKFwidmVyc2lvblwiLFwiMS4xXCIpLGkuc2V0QXR0cmlidXRlKFwicHJlc2VydmVBc3BlY3RSYXRpb1wiLFwieE1pbllNaW4gc2xpY2VcIiksaS5zdHlsZS5maWxsUnVsZT1cImV2ZW5vZGRcIixpLnN0eWxlLmNsaXBSdWxlPVwiZXZlbm9kZFwiLGkuc3R5bGUuc3Ryb2tlTGluZWpvaW49XCJyb3VuZFwiLGkuc3R5bGUuc3Ryb2tlTWl0ZXJsaW1pdD1cIjJcIixpLnN0eWxlLnpJbmRleD1cIjEwMDAwXCIsaS5zdHlsZS5wb3NpdGlvbj1cImZpeGVkXCIsaS5zdHlsZS50b3A9XCIwXCIsaS5zdHlsZS5sZWZ0PVwiMFwiLGkuc3R5bGUud2lkdGg9XCIxMDAlXCIsaS5zdHlsZS5oZWlnaHQ9XCIxMDAlXCI7Y29uc3QgcD1kb2N1bWVudC5jcmVhdGVFbGVtZW50TlMoXCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiLFwicGF0aFwiKTtyZXR1cm4gcC5zZXRBdHRyaWJ1dGUoXCJkXCIsaihlKSkscC5zdHlsZS5maWxsPWEoXCJvdmVybGF5Q29sb3JcIil8fFwicmdiKDAsMCwwKVwiLHAuc3R5bGUub3BhY2l0eT1gJHthKFwib3ZlcmxheU9wYWNpdHlcIil9YCxwLnN0eWxlLnBvaW50ZXJFdmVudHM9XCJhdXRvXCIscC5zdHlsZS5jdXJzb3I9XCJhdXRvXCIsaS5hcHBlbmRDaGlsZChwKSxpfWZ1bmN0aW9uIGooZSl7Y29uc3Qgbz13aW5kb3cuaW5uZXJXaWR0aCx0PXdpbmRvdy5pbm5lckhlaWdodCxpPWEoXCJzd
Line truncated
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="/css/themes.css">
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/css/xterm.css">
|
||||
<link rel="stylesheet" href="/css/caddy-builder.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -201,13 +202,12 @@
|
||||
<span class="tools-section-label">Tools</span>
|
||||
</button>
|
||||
<div class="tools-section-items">
|
||||
<button id="caddy-builder-btn" aria-label="Visual reverse proxy builder">🔧 Reverse Proxy Builder</button>
|
||||
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</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="log-insights-btn" aria-label="Log Insights">🔍 Insights</button>
|
||||
<button onclick="openDiskSettings()" aria-label="Disk Safety">💾 Disk</button>
|
||||
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -654,23 +654,6 @@
|
||||
<!-- 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);">
|
||||
@@ -680,49 +663,7 @@
|
||||
|
||||
<div class="setup-wizard-buttons">
|
||||
<button id="setup-summary-back">← Back</button>
|
||||
<button id="setup-summary-next" class="setup-btn-primary">Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk Safety Warning step (shown after the configuration summary) -->
|
||||
<div class="setup-step" id="setup-step-disk-safety" style="display: none;">
|
||||
<h2 style="margin: 0 0 8px;">⚠️ Disk Usage Note</h2>
|
||||
<p class="setup-desc">Important information about storage before you finish</p>
|
||||
|
||||
<div style="margin-top: 8px; padding: 18px 20px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 12%, transparent); border-radius: 10px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||
<div style="display: flex; gap: 12px; align-items: flex-start;">
|
||||
<span style="font-size: 1.5rem; line-height: 1.2;">⚠️</span>
|
||||
<div style="font-size: 0.92rem; line-height: 1.55; color: var(--text);">
|
||||
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage Note:</strong>
|
||||
DashCaddy stores health check history, container statistics, and event logs.
|
||||
On a busy server, this data can accumulate over time.
|
||||
Set appropriate retention limits in <strong>Settings → Health</strong> to prevent disk fill.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; padding: 14px 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<strong style="font-size: 0.9rem;">📋 Recommended after setup</strong>
|
||||
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 0.85rem; color: var(--muted); line-height: 1.6;">
|
||||
<li>Open <strong>Health → Configure → Global Settings</strong></li>
|
||||
<li>Set a <strong>health check polling interval</strong> (default: 60s)</li>
|
||||
<li>Set a <strong>stats polling interval</strong> (default: 30s)</li>
|
||||
<li>Set a <strong>data retention period</strong> (default: 30 days)</li>
|
||||
<li>Cap <strong>max entries per service</strong> (default: 500)</li>
|
||||
<li>Set a <strong>disk-usage warning threshold</strong> (default: 80%)</li>
|
||||
</ul>
|
||||
</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);">
|
||||
Go to Settings → System Configuration to edit your setup anytime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-wizard-buttons">
|
||||
<button id="setup-disk-safety-back">← Back</button>
|
||||
<button id="setup-disk-safety-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||||
<button id="setup-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1011,22 +952,13 @@
|
||||
<script src="/js/xterm-fit.min.js" defer></script>
|
||||
|
||||
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
|
||||
<script src="/js/log-insights.js" defer></script>
|
||||
<script src="/js/disk-settings.js" defer></script>
|
||||
<script src="/js/ai-chat.js" defer></script>
|
||||
<script src="/js/tailscale-devices.js" defer></script>
|
||||
<script src="/js/language-selector.js" defer></script>
|
||||
|
||||
<!-- Bundled JS (built with: npm run build) -->
|
||||
<!-- i18n (language selector + translation system) -->
|
||||
<script src="/js/i18n.js" defer></script>
|
||||
|
||||
<script src="/dist/core.js" defer></script>
|
||||
<script src="/dist/features.js" defer></script>
|
||||
<script src="/dist/onboarding.js" defer></script>
|
||||
<script src="/dist/init.js" defer></script>
|
||||
<!-- AI Chat Floating Button -->
|
||||
<button onclick="toggleAIChat()" style="position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#8b5cf6);border:none;box-shadow:0 4px 20px rgba(99,102,241,0.4);cursor:pointer;z-index:9998;font-size:1.5rem;display:flex;align-items:center;justify-content:center;transition:transform 0.2s;" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'" title="AI Assistant">🤖</button>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,173 +0,0 @@
|
||||
// AI Chat Panel — DashCaddy AI Intent Router frontend
|
||||
(function() {
|
||||
let chatMessages = [];
|
||||
let chatHistory = [];
|
||||
|
||||
function createChatPanel() {
|
||||
const existing = document.getElementById('ai-chat-panel');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'ai-chat-panel';
|
||||
panel.style.cssText = `
|
||||
position: fixed; bottom: 80px; right: 20px; width: 400px; max-width: 95vw;
|
||||
height: 500px; max-height: 70vh; background: var(--bg-card, #1a1a2e);
|
||||
border: 1px solid var(--border, #333); border-radius: 12px;
|
||||
display: none; flex-direction: column; z-index: 9999;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
`;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div style="padding: 14px 18px; border-bottom: 1px solid var(--border,#333); display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.3rem;">🤖</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight: 700; font-size: 0.95rem;">AI Assistant</div>
|
||||
<div id="ai-status" style="font-size: 0.72rem; color: var(--text-muted,#666);">Pattern matching • No key needed</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('ai-chat-panel').style.display='none'" style="background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-muted,#888);">×</button>
|
||||
</div>
|
||||
<div id="ai-chat-messages" style="flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="background:rgba(99,102,241,0.08); border-radius:10px; padding:12px; font-size:0.85rem; color:var(--text-muted,#aaa);">
|
||||
👋 Hi! I can help you deploy apps, diagnose issues, and manage your server. Try:
|
||||
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Deploy Plex"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Deploy Plex</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"I want to stream movies"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Stream movies</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Block ads on my network"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Block ads</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Is everything OK?')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">System health</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px; border-top:1px solid var(--border,#333);">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input id="ai-chat-input" type="text" placeholder="Ask me anything..."
|
||||
onkeypress="if(event.key==='Enter') window.aiChat.send()"
|
||||
style="flex:1; background:var(--bg-input,#0f0f1a); border:1px solid var(--border,#333); border-radius:8px; padding:10px 14px; color:var(--text,#fff); font-size:0.88rem; outline:none;">
|
||||
<button onclick="window.aiChat.send()" style="background:#6366f1; border:none; border-radius:8px; padding:0 16px; color:#fff; cursor:pointer; font-weight:600;">→</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
let panel = document.getElementById('ai-chat-panel');
|
||||
if (!panel) { createChatPanel(); panel = document.getElementById('ai-chat-panel'); }
|
||||
panel.style.display = panel.style.display === 'flex' ? 'none' : 'flex';
|
||||
if (panel.style.display === 'flex') document.getElementById('ai-chat-input').focus();
|
||||
}
|
||||
|
||||
function addMessage(role, text, extra) {
|
||||
const container = document.getElementById('ai-chat-messages');
|
||||
if (!container) return;
|
||||
const div = document.createElement('div');
|
||||
const isUser = role === 'user';
|
||||
div.style.cssText = isUser
|
||||
? 'align-self:flex-end; background:rgba(99,102,241,0.15); border-radius:10px 10px 2px 10px; padding:10px 14px; max-width:85%; font-size:0.85rem;'
|
||||
: 'align-self:flex-start; background:rgba(255,255,255,0.05); border-radius:10px 10px 10px 2px; padding:10px 14px; max-width:85%; font-size:0.85rem;';
|
||||
div.textContent = text;
|
||||
container.appendChild(div);
|
||||
|
||||
// Extra content (recommendations, action buttons)
|
||||
if (extra) {
|
||||
const extraDiv = document.createElement('div');
|
||||
extraDiv.style.cssText = 'align-self:flex-start; max-width:85%; margin-top:4px;';
|
||||
if (extra.disclaimer) {
|
||||
const disc = document.createElement('div');
|
||||
disc.style.cssText = 'font-size:0.7rem; color:var(--text-muted,#666); padding:6px 8px; line-height:1.4; margin-top:4px;';
|
||||
disc.textContent = '⚠️ ' + extra.disclaimer;
|
||||
extraDiv.appendChild(disc);
|
||||
}
|
||||
if (extra.recommendations) {
|
||||
extra.recommendations.forEach(rec => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = `📦 Deploy ${rec.app}`;
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
|
||||
btn.onclick = () => window.aiChat.send('Deploy ' + rec.app);
|
||||
extraDiv.appendChild(btn);
|
||||
});
|
||||
}
|
||||
if (extra.appId) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = `🚀 Deploy ${extra.appId}`;
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
|
||||
btn.onclick = () => window.aiChat.deploy(extra.appId);
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
if (extra.suggestions) {
|
||||
const list = document.createElement('div');
|
||||
list.style.cssText = 'font-size:0.78rem; color:var(--text-muted,#888); padding:4px 0;';
|
||||
list.innerHTML = extra.suggestions.map(s => '• ' + s).join('<br>');
|
||||
extraDiv.appendChild(list);
|
||||
}
|
||||
if (extra.action === 'diagnose') {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '🔍 Run Diagnostics';
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#fbbf24;';
|
||||
btn.onclick = () => window.location.href = '#health';
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
if (extra.action === 'backup') {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '💾 Create Backup';
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#a5b4fc;';
|
||||
btn.onclick = () => { if (window.createBackup) window.createBackup(); };
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
container.appendChild(extraDiv);
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
async function send(prefilled) {
|
||||
const input = document.getElementById('ai-chat-input');
|
||||
const message = prefilled || input.value.trim();
|
||||
if (!message) return;
|
||||
input.value = '';
|
||||
|
||||
addMessage('user', message);
|
||||
|
||||
// Typing indicator
|
||||
const container = document.getElementById('ai-chat-messages');
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'ai-typing';
|
||||
typing.style.cssText = 'align-self:flex-start; color:var(--text-muted,#666); font-size:0.8rem; padding:8px 14px;';
|
||||
typing.textContent = '🤖 Thinking...';
|
||||
container.appendChild(typing);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/ai/intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, context: {} })
|
||||
});
|
||||
const data = await res.json();
|
||||
typing.remove();
|
||||
|
||||
if (data.success !== false && data.data) {
|
||||
const r = data.data;
|
||||
const response = r.response || {};
|
||||
addMessage('ai', response.message || r.response?.message || 'Action: ' + (r.action || r.intent), response);
|
||||
} else {
|
||||
addMessage('ai', data.error || 'Sorry, something went wrong.');
|
||||
}
|
||||
} catch (e) {
|
||||
typing.remove();
|
||||
addMessage('ai', 'Connection error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deploy(appId) {
|
||||
addMessage('user', 'Deploy ' + appId);
|
||||
addMessage('ai', `Launching ${appId} from the catalog...`);
|
||||
// Trigger the app selector with this app
|
||||
if (window.openAppSelector) {
|
||||
window.openAppSelector(appId);
|
||||
} else {
|
||||
addMessage('ai', `Search for ${appId} in the App Catalog to deploy it.`);
|
||||
}
|
||||
}
|
||||
|
||||
window.aiChat = { send, deploy, toggle };
|
||||
window.toggleAIChat = toggle;
|
||||
})();
|
||||
@@ -0,0 +1,504 @@
|
||||
// ========== CADDY VISUAL BUILDER (DC-106) ==========
|
||||
// Visual reverse-proxy builder. Lets the user describe what they want
|
||||
// in plain form fields ("blog.yourdomain.com → container X on port 80,
|
||||
// with auth, rate limiting, compression") and renders the corresponding
|
||||
// Caddyfile snippet. Uses the existing /api/v1/caddycode/generate +
|
||||
// /caddycode/validate + /caddycode/templates endpoints.
|
||||
//
|
||||
// Design: stateless — every keystroke rebuilds the snippet via debounced
|
||||
// fetch. State machine is a single plain-object `state` snapshot. No
|
||||
// external libraries. Matches the existing log-insights.js IIFE pattern.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// --- Constants ---------------------------------------------------------
|
||||
const DEBOUNCE_MS = 250;
|
||||
const TEMPLATE_PRESETS = [
|
||||
{ id: 'simple-proxy', label: 'Simple reverse proxy' },
|
||||
{ id: 'websocket-app', label: 'WebSocket application' },
|
||||
{ id: 'auth-gated', label: 'Auth-gated (DashCaddy SSO)' },
|
||||
{ id: 'cors-api', label: 'API with CORS' },
|
||||
{ id: 'subdirectory', label: 'Subdirectory proxy' },
|
||||
];
|
||||
|
||||
// --- State -------------------------------------------------------------
|
||||
// Single source of truth for the form. Updates flow in via setState,
|
||||
// which triggers debounced regeneration.
|
||||
const state = {
|
||||
domain: 'blog.example.com',
|
||||
upstream: 'localhost:8080',
|
||||
upstreamProtocol: 'http',
|
||||
tls: 'auto',
|
||||
auth: false,
|
||||
authService: '',
|
||||
websocket: false,
|
||||
cors: false,
|
||||
compress: true,
|
||||
stripPrefix: '',
|
||||
redirectToHttps: true,
|
||||
headers: [], // [{ key, value }]
|
||||
caddyfile: '',
|
||||
validationIssues: [],
|
||||
lastError: '',
|
||||
generating: false,
|
||||
};
|
||||
|
||||
let regenTimer = null;
|
||||
let validateTimer = null;
|
||||
|
||||
// --- DOM injection -----------------------------------------------------
|
||||
injectModal('caddy-builder-modal', `<div id="caddy-builder-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 920px; max-width: 1100px;">
|
||||
<h3>🔧 Reverse Proxy Builder</h3>
|
||||
<p class="modal-subtitle">
|
||||
Describe your reverse proxy in plain fields. Get a Caddyfile snippet you can
|
||||
paste into <code>/etc/caddy/Caddyfile</code> and reload with
|
||||
<code>caddy-apply</code>.
|
||||
</p>
|
||||
|
||||
<!-- Template picker -->
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 14px;">
|
||||
<label class="text-muted-sm">Template:</label>
|
||||
<select id="cb-template" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">— Custom —</option>
|
||||
</select>
|
||||
<button id="cb-load-template" class="btn-sm">📋 Load template</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<span id="cb-status" class="text-muted-sm"></span>
|
||||
</div>
|
||||
|
||||
<!-- Form (left) + preview (right) -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||
<div>
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Routing</h4>
|
||||
<label class="cb-label">Domain
|
||||
<input type="text" id="cb-domain" placeholder="blog.example.com" autocomplete="off" />
|
||||
<small>Public hostname clients will use.</small>
|
||||
</label>
|
||||
<label class="cb-label">Upstream (host:port)
|
||||
<input type="text" id="cb-upstream" placeholder="localhost:8080" autocomplete="off" />
|
||||
<small>Where requests go. <code>localhost:8080</code>, <code>my-container:80</code>, or <code>[::1]:5000</code>.</small>
|
||||
</label>
|
||||
<label class="cb-label">Upstream protocol
|
||||
<select id="cb-upstream-protocol">
|
||||
<option value="http">http://</option>
|
||||
<option value="https">https://</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cb-label">Strip prefix (optional)
|
||||
<input type="text" id="cb-strip-prefix" placeholder="/api" autocomplete="off" />
|
||||
<small>Removes this prefix from the URL before proxying. E.g. <code>/api</code> rewrites <code>/api/users</code> → <code>/users</code>.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">TLS</h4>
|
||||
<label class="cb-label">TLS mode
|
||||
<select id="cb-tls">
|
||||
<option value="auto">auto (Caddy issues Let's Encrypt)</option>
|
||||
<option value="internal">internal (private CA only)</option>
|
||||
<option value="letsencrypt">letsencrypt (explicit)</option>
|
||||
</select>
|
||||
<small><code>auto</code> is the default for any public hostname.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Behavior</h4>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-websocket" /> WebSocket support</label>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-cors" /> CORS headers (Access-Control-Allow-Origin: *)</label>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-compress" checked /> Compression (gzip + zstd)</label>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-redirect-https" checked /> HTTP→HTTPS redirect (default in Caddy 2)</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">DashCaddy SSO</h4>
|
||||
<label class="cb-checkbox"><input type="checkbox" id="cb-auth" /> Gate behind DashCaddy auth</label>
|
||||
<label class="cb-label">Service ID (for auth)
|
||||
<input type="text" id="cb-auth-service" placeholder="blog" autocomplete="off" />
|
||||
<small>Must match a service in DashCaddy's catalog (lowercase, hyphens).</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cb-section">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.95rem;">Custom headers</h4>
|
||||
<div id="cb-headers-list"></div>
|
||||
<button id="cb-add-header" class="btn-sm" style="margin-top: 8px;">+ Add header</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="cb-section" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<h4 style="margin: 0; font-size: 0.95rem;">Generated Caddyfile</h4>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="cb-copy" class="btn-sm">📋 Copy</button>
|
||||
<button id="cb-validate-btn" class="btn-sm">✓ Validate</button>
|
||||
</div>
|
||||
<pre id="cb-preview" class="cb-preview"><code></code></pre>
|
||||
<div id="cb-validation" class="cb-validation"></div>
|
||||
<div id="cb-error" class="cb-error" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons">
|
||||
<button id="cb-reset">Reset</button>
|
||||
<button id="cb-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('caddy-builder-modal');
|
||||
const openBtn = document.getElementById('caddy-builder-btn');
|
||||
const closeBtn = document.getElementById('cb-close');
|
||||
const resetBtn = document.getElementById('cb-reset');
|
||||
const templateSel = document.getElementById('cb-template');
|
||||
const loadTplBtn = document.getElementById('cb-load-template');
|
||||
const statusEl = document.getElementById('cb-status');
|
||||
const previewEl = document.getElementById('cb-preview').querySelector('code');
|
||||
const validationEl = document.getElementById('cb-validation');
|
||||
const errorEl = document.getElementById('cb-error');
|
||||
const headersList = document.getElementById('cb-headers-list');
|
||||
const addHeaderBtn = document.getElementById('cb-add-header');
|
||||
const copyBtn = document.getElementById('cb-copy');
|
||||
const validateBtn = document.getElementById('cb-validate-btn');
|
||||
|
||||
// Form field references
|
||||
const fields = {
|
||||
domain: document.getElementById('cb-domain'),
|
||||
upstream: document.getElementById('cb-upstream'),
|
||||
upstreamProtocol: document.getElementById('cb-upstream-protocol'),
|
||||
tls: document.getElementById('cb-tls'),
|
||||
auth: document.getElementById('cb-auth'),
|
||||
authService: document.getElementById('cb-auth-service'),
|
||||
websocket: document.getElementById('cb-websocket'),
|
||||
cors: document.getElementById('cb-cors'),
|
||||
compress: document.getElementById('cb-compress'),
|
||||
stripPrefix: document.getElementById('cb-strip-prefix'),
|
||||
redirectToHttps: document.getElementById('cb-redirect-https'),
|
||||
};
|
||||
|
||||
// --- Template loading --------------------------------------------------
|
||||
let availableTemplates = {};
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/caddycode/templates', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (data && data.templates) {
|
||||
availableTemplates = data.templates;
|
||||
// Populate the picker
|
||||
templateSel.innerHTML = '<option value="">— Custom —</option>';
|
||||
for (const preset of TEMPLATE_PRESETS) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = preset.id;
|
||||
opt.textContent = preset.label;
|
||||
templateSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[caddy-builder] Failed to load templates:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTemplate(id) {
|
||||
const tpl = availableTemplates[id];
|
||||
if (!tpl || !tpl.config) return;
|
||||
const cfg = tpl.config;
|
||||
if (cfg.domain != null) state.domain = cfg.domain;
|
||||
if (cfg.upstream != null) state.upstream = cfg.upstream;
|
||||
if (cfg.upstreamProtocol != null) state.upstreamProtocol = cfg.upstreamProtocol;
|
||||
if (cfg.tls != null) state.tls = cfg.tls;
|
||||
if (cfg.auth != null) state.auth = !!cfg.auth;
|
||||
if (cfg.authService != null) state.authService = cfg.authService || '';
|
||||
if (cfg.websocket != null) state.websocket = !!cfg.websocket;
|
||||
if (cfg.cors != null) state.cors = !!cfg.cors;
|
||||
if (cfg.compress != null) state.compress = !!cfg.compress;
|
||||
if (cfg.stripPrefix != null) state.stripPrefix = cfg.stripPrefix || '';
|
||||
if (cfg.redirectToHttps != null) state.redirectToHttps = !!cfg.redirectToHttps;
|
||||
if (Array.isArray(cfg.headers)) state.headers = cfg.headers.slice();
|
||||
syncFieldsFromState();
|
||||
triggerRegen();
|
||||
setStatus('Template loaded: ' + (tpl.label || id));
|
||||
}
|
||||
|
||||
// --- Headers list ------------------------------------------------------
|
||||
function renderHeadersList() {
|
||||
headersList.innerHTML = '';
|
||||
state.headers.forEach((h, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cb-header-row';
|
||||
row.innerHTML = `
|
||||
<input type="text" class="cb-h-key" placeholder="Header-Name" value="${escapeHtml(h.key || '')}" data-idx="${idx}" />
|
||||
<input type="text" class="cb-h-value" placeholder="value" value="${escapeHtml(h.value || '')}" data-idx="${idx}" />
|
||||
<button class="cb-h-remove" data-idx="${idx}" title="Remove">✕</button>
|
||||
`;
|
||||
headersList.appendChild(row);
|
||||
});
|
||||
// Wire handlers
|
||||
headersList.querySelectorAll('.cb-h-key').forEach(el => {
|
||||
el.addEventListener('input', e => {
|
||||
const i = +e.target.dataset.idx;
|
||||
state.headers[i].key = e.target.value;
|
||||
triggerRegen();
|
||||
});
|
||||
});
|
||||
headersList.querySelectorAll('.cb-h-value').forEach(el => {
|
||||
el.addEventListener('input', e => {
|
||||
const i = +e.target.dataset.idx;
|
||||
state.headers[i].value = e.target.value;
|
||||
triggerRegen();
|
||||
});
|
||||
});
|
||||
headersList.querySelectorAll('.cb-h-remove').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const i = +e.currentTarget.dataset.idx;
|
||||
state.headers.splice(i, 1);
|
||||
renderHeadersList();
|
||||
triggerRegen();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generation --------------------------------------------------------
|
||||
function buildPayload() {
|
||||
const headers = {};
|
||||
for (const h of state.headers) {
|
||||
if (h.key && h.key.trim()) {
|
||||
headers[h.key.trim()] = h.value || '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
domain: state.domain.trim(),
|
||||
upstream: state.upstream.trim(),
|
||||
upstreamProtocol: state.upstreamProtocol,
|
||||
tls: state.tls,
|
||||
auth: state.auth,
|
||||
authService: state.auth ? state.authService.trim() || null : null,
|
||||
websocket: state.websocket,
|
||||
cors: state.cors,
|
||||
compress: state.compress,
|
||||
stripPrefix: state.stripPrefix.trim() || null,
|
||||
redirectToHttps: state.redirectToHttps,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
function triggerRegen() {
|
||||
clearTimeout(regenTimer);
|
||||
regenTimer = setTimeout(generate, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function triggerValidate() {
|
||||
clearTimeout(validateTimer);
|
||||
validateTimer = setTimeout(validateGenerated, DEBOUNCE_MS + 100);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const payload = buildPayload();
|
||||
if (!payload.domain || !payload.upstream) {
|
||||
previewEl.textContent = '(fill in domain + upstream to generate)';
|
||||
state.caddyfile = '';
|
||||
validationEl.innerHTML = '';
|
||||
errorEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
state.generating = true;
|
||||
setStatus('Generating…');
|
||||
try {
|
||||
const res = await fetch('/api/v1/caddycode/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
state.caddyfile = '';
|
||||
previewEl.textContent = '';
|
||||
const errs = (data && data.errors) || [data.error || 'Generation failed'];
|
||||
showValidationErrors(errs);
|
||||
setStatus('Validation failed');
|
||||
return;
|
||||
}
|
||||
state.caddyfile = data.caddyfile || '';
|
||||
previewEl.textContent = state.caddyfile;
|
||||
errorEl.style.display = 'none';
|
||||
validationEl.innerHTML = '';
|
||||
setStatus('✓ Generated');
|
||||
triggerValidate();
|
||||
} catch (err) {
|
||||
showError('Network error: ' + (err.message || err));
|
||||
setStatus('Network error');
|
||||
} finally {
|
||||
state.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGenerated() {
|
||||
if (!state.caddyfile) {
|
||||
validationEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/v1/caddycode/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ caddyfile: state.caddyfile }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.issues && data.issues.length) {
|
||||
showValidationErrors(data.issues, data.warnings || []);
|
||||
} else {
|
||||
validationEl.innerHTML = '<span class="cb-ok">✓ Valid</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
// silent — generation status already covers network errors
|
||||
}
|
||||
}
|
||||
|
||||
function showValidationErrors(errors, warnings) {
|
||||
let html = '<div class="cb-issues">';
|
||||
errors.forEach(e => { html += `<div class="cb-err">⚠ ${escapeHtml(e)}</div>`; });
|
||||
(warnings || []).forEach(w => { html += `<div class="cb-warn">⚠ ${escapeHtml(w)}</div>`; });
|
||||
html += '</div>';
|
||||
validationEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
if (text.startsWith('✓') || text.startsWith('Template')) {
|
||||
setTimeout(() => {
|
||||
if (statusEl.textContent === text) statusEl.textContent = '';
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Field → state sync ------------------------------------------------
|
||||
function syncFieldsFromState() {
|
||||
fields.domain.value = state.domain;
|
||||
fields.upstream.value = state.upstream;
|
||||
fields.upstreamProtocol.value = state.upstreamProtocol;
|
||||
fields.tls.value = state.tls;
|
||||
fields.auth.checked = state.auth;
|
||||
fields.authService.value = state.authService;
|
||||
fields.authService.disabled = !state.auth;
|
||||
fields.websocket.checked = state.websocket;
|
||||
fields.cors.checked = state.cors;
|
||||
fields.compress.checked = state.compress;
|
||||
fields.stripPrefix.value = state.stripPrefix;
|
||||
fields.redirectToHttps.checked = state.redirectToHttps;
|
||||
renderHeadersList();
|
||||
}
|
||||
|
||||
function bindFieldEvents() {
|
||||
fields.domain.addEventListener('input', e => { state.domain = e.target.value; triggerRegen(); });
|
||||
fields.upstream.addEventListener('input', e => { state.upstream = e.target.value; triggerRegen(); });
|
||||
fields.upstreamProtocol.addEventListener('change', e => { state.upstreamProtocol = e.target.value; triggerRegen(); });
|
||||
fields.tls.addEventListener('change', e => { state.tls = e.target.value; triggerRegen(); });
|
||||
fields.auth.addEventListener('change', e => {
|
||||
state.auth = e.target.checked;
|
||||
fields.authService.disabled = !state.auth;
|
||||
triggerRegen();
|
||||
});
|
||||
fields.authService.addEventListener('input', e => { state.authService = e.target.value; triggerRegen(); });
|
||||
fields.websocket.addEventListener('change', e => { state.websocket = e.target.checked; triggerRegen(); });
|
||||
fields.cors.addEventListener('change', e => { state.cors = e.target.checked; triggerRegen(); });
|
||||
fields.compress.addEventListener('change', e => { state.compress = e.target.checked; triggerRegen(); });
|
||||
fields.stripPrefix.addEventListener('input', e => { state.stripPrefix = e.target.value; triggerRegen(); });
|
||||
fields.redirectToHttps.addEventListener('change', e => { state.redirectToHttps = e.target.checked; triggerRegen(); });
|
||||
}
|
||||
|
||||
// --- Buttons -----------------------------------------------------------
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
modal.style.display = 'flex';
|
||||
syncFieldsFromState();
|
||||
generate();
|
||||
});
|
||||
}
|
||||
closeBtn.addEventListener('click', () => { modal.style.display = 'none'; });
|
||||
resetBtn.addEventListener('click', () => {
|
||||
state.domain = 'blog.example.com';
|
||||
state.upstream = 'localhost:8080';
|
||||
state.upstreamProtocol = 'http';
|
||||
state.tls = 'auto';
|
||||
state.auth = false;
|
||||
state.authService = '';
|
||||
state.websocket = false;
|
||||
state.cors = false;
|
||||
state.compress = true;
|
||||
state.stripPrefix = '';
|
||||
state.redirectToHttps = true;
|
||||
state.headers = [];
|
||||
templateSel.value = '';
|
||||
syncFieldsFromState();
|
||||
triggerRegen();
|
||||
setStatus('Reset');
|
||||
});
|
||||
|
||||
loadTplBtn.addEventListener('click', () => {
|
||||
const id = templateSel.value;
|
||||
if (!id) {
|
||||
setStatus('Pick a template first');
|
||||
return;
|
||||
}
|
||||
applyTemplate(id);
|
||||
});
|
||||
|
||||
addHeaderBtn.addEventListener('click', () => {
|
||||
state.headers.push({ key: '', value: '' });
|
||||
renderHeadersList();
|
||||
});
|
||||
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
if (!state.caddyfile) {
|
||||
setStatus('Nothing to copy');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(state.caddyfile);
|
||||
setStatus('✓ Copied to clipboard');
|
||||
} catch (err) {
|
||||
// Fallback: select the preview
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(previewEl.parentNode);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
setStatus('Selected — press ⌘/Ctrl-C to copy');
|
||||
}
|
||||
});
|
||||
|
||||
validateBtn.addEventListener('click', () => {
|
||||
if (!state.caddyfile) {
|
||||
setStatus('Generate first');
|
||||
return;
|
||||
}
|
||||
validateGenerated();
|
||||
setStatus('Validated');
|
||||
});
|
||||
|
||||
// --- Init --------------------------------------------------------------
|
||||
bindFieldEvents();
|
||||
syncFieldsFromState();
|
||||
loadTemplates();
|
||||
|
||||
// Expose for testing
|
||||
window.__caddyBuilder = {
|
||||
state,
|
||||
generate,
|
||||
validateGenerated,
|
||||
applyTemplate,
|
||||
buildPayload,
|
||||
getCaddyfile: () => state.caddyfile,
|
||||
};
|
||||
})();
|
||||
@@ -1,124 +0,0 @@
|
||||
// Disk Safety Settings Panel
|
||||
(function() {
|
||||
let diskSettings = null;
|
||||
|
||||
async function loadDiskSettings() {
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/disk-settings');
|
||||
if (res.ok) {
|
||||
diskSettings = await res.json();
|
||||
renderDiskSettingsModal();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load disk settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiskSettingsModal() {
|
||||
const existing = document.getElementById('disk-settings-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const c = diskSettings?.current || {};
|
||||
const du = diskSettings?.diskUsage || {};
|
||||
const usedGB = (du.dataDirSize / 1073741824).toFixed(2);
|
||||
const diskFreeGB = (du.free / 1073741824).toFixed(1);
|
||||
const diskTotalGB = (du.total / 1073741824).toFixed(1);
|
||||
const diskPct = du.total > 0 ? ((du.used / du.total) * 100).toFixed(1) : 0;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'disk-settings-modal';
|
||||
modal.className = 'modal';
|
||||
modal.style.cssText = 'display:flex;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center;';
|
||||
modal.innerHTML = `
|
||||
<div style="background:var(--bg-card,#1a1a2e);border-radius:12px;padding:28px;max-width:520px;width:90%;max-height:85vh;overflow-y:auto;border:1px solid var(--border,#333);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||
<h2 style="margin:0;font-size:1.3rem;">💾 Disk Safety</h2>
|
||||
<button onclick="document.getElementById('disk-settings-modal').remove()" style="background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--text-muted,#888);">×</button>
|
||||
</div>
|
||||
|
||||
<div style="background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.2);border-radius:8px;padding:14px;margin-bottom:20px;">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
|
||||
<span style="font-size:0.9rem;color:var(--text-muted,#888);">DashCaddy Data Size</span>
|
||||
<span style="font-weight:600;">${usedGB} GB</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
|
||||
<span style="font-size:0.9rem;color:var(--text-muted,#888);">Disk Free</span>
|
||||
<span style="font-weight:600;">${diskFreeGB} GB / ${diskTotalGB} GB (${diskPct}% used)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:16px;">
|
||||
${settingRow('Health Check Interval', 'healthInterval', c.healthCheckInterval, 'seconds', Math.round((c.healthCheckInterval||30000)/1000), 5, 300)}
|
||||
${settingRow('Max Health Entries/Service', 'healthMaxEntries', c.healthMaxEntries, 'entries', c.healthMaxEntries||500, 50, 5000)}
|
||||
${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||14, 1, 90)}
|
||||
${settingRow('Max Stats Entries', 'statsMaxEntries', c.statsMaxEntries, 'entries', c.statsMaxEntries||500, 100, 10000)}
|
||||
${settingRow('Max Audit Entries', 'auditMaxEntries', c.auditMaxEntries, 'entries', c.auditMaxEntries||1000, 100, 10000)}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:24px;">
|
||||
<button onclick="saveDiskSettings()" style="flex:1;padding:10px 16px;background:#6366f1;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;">Save Settings</button>
|
||||
<button onclick="cleanupDiskNow()" style="flex:1;padding:10px 16px;background:rgba(239,68,68,0.15);color:#f87171;border:1px solid rgba(239,68,68,0.3);border-radius:8px;cursor:pointer;font-weight:600;">Clean Up Now</button>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;color:var(--text-muted,#666);margin-top:12px;text-align:center;">Some changes apply on next container restart.</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
function settingRow(label, id, current, unit, displayVal, min, max) {
|
||||
return `
|
||||
<div>
|
||||
<label style="font-size:0.85rem;color:var(--text-muted,#aaa);display:block;margin-bottom:4px;">${label}</label>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<input type="range" id="disk-${id}" min="${min}" max="${max}" value="${displayVal}" oninput="document.getElementById('disk-${id}-val').textContent=this.value"
|
||||
style="flex:1;accent-color:#6366f1;">
|
||||
<span id="disk-${id}-val" style="min-width:50px;text-align:right;font-weight:600;">${displayVal}</span>
|
||||
<span style="font-size:0.8rem;color:var(--text-muted,#666);min-width:60px;">${unit}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function saveDiskSettings() {
|
||||
const payload = {
|
||||
healthInterval: parseInt(document.getElementById('disk-healthInterval').value) * 1000,
|
||||
healthMaxEntries: parseInt(document.getElementById('disk-healthMaxEntries').value),
|
||||
healthRetentionDays: parseInt(document.getElementById('disk-healthRetentionDays').value),
|
||||
statsMaxEntries: parseInt(document.getElementById('disk-statsMaxEntries').value),
|
||||
auditMaxEntries: parseInt(document.getElementById('disk-auditMaxEntries').value),
|
||||
};
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/disk-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast('Disk settings saved', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Error: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDiskNow() {
|
||||
if (!confirm('Clean up old health entries, stats, and audit logs now?')) return;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/disk-settings/cleanup', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const items = Object.entries(data.results.cleaned).map(([k,v]) => `${k}: ${v}`).join('\n');
|
||||
showToast('Cleanup complete', 'success');
|
||||
loadDiskSettings();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Error: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window.openDiskSettings = function() { loadDiskSettings(); };
|
||||
window.saveDiskSettings = saveDiskSettings;
|
||||
window.cleanupDiskNow = cleanupDiskNow;
|
||||
})();
|
||||
@@ -1,28 +1,10 @@
|
||||
// ===== DASHBOARD CONSTANTS =====
|
||||
// Honor persisted health retention settings for polling cadences so the
|
||||
// Settings → Health → Global Settings panel actually takes effect. The
|
||||
// STATS interval (resource/container stat sampling) is driven from the
|
||||
// user-configurable statsPollingInterval. HEALTH is the lightweight card
|
||||
// badge refresh and stays at its fast default unless overridden.
|
||||
(function applyHealthPollingSettings() {
|
||||
try {
|
||||
var raw = (typeof localStorage !== 'undefined' && localStorage.getItem('dashcaddy-health-settings')) || null;
|
||||
if (raw) {
|
||||
var s = JSON.parse(raw);
|
||||
// values are stored in seconds; DC.POLL expects milliseconds
|
||||
if (s.statsPollingInterval && s.statsPollingInterval >= 5 && s.statsPollingInterval <= 3600) {
|
||||
window.__DC_STATS_OVERRIDE = s.statsPollingInterval * 1000;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore — fall back to defaults below */ }
|
||||
})();
|
||||
|
||||
const DC = {
|
||||
NAME: 'DashCaddy',
|
||||
POLL: {
|
||||
DASHBOARD: 10000, // 10s — main refreshAll interval
|
||||
LOGS: 3000, // 3s — log viewer updates
|
||||
STATS: (typeof window !== 'undefined' && window.__DC_STATS_OVERRIDE) || 5000, // 5s default — resource monitor refresh (overridable via Settings → Health)
|
||||
STATS: 5000, // 5s — resource monitor refresh
|
||||
WEATHER: 600000, // 10m — weather widget refresh
|
||||
HEALTH: 1000, // 1s — card health badge refresh
|
||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
||||
@@ -215,7 +197,6 @@ async function secureFetch(url, options = {}) {
|
||||
options = { ...options, signal: AbortSignal.timeout(15000) };
|
||||
}
|
||||
|
||||
options.credentials = options.credentials || 'same-origin';
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Auto-retry once on CSRF failure (token may have been rotated by TOTP re-auth)
|
||||
|
||||
@@ -34,44 +34,6 @@
|
||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Settings: retention, polling intervals, max entries, 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; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Health Check Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-interval" value="60" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service's health endpoint is checked.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Stats Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-stats-interval" value="30" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often container statistics (CPU/memory) are sampled.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Max Entries Per Service</label>
|
||||
<input type="number" id="health-setting-max-entries" value="500" min="10" max="100000" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Cap on stored history records per service.</div>
|
||||
</div>
|
||||
<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">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>
|
||||
@@ -141,77 +103,6 @@
|
||||
const formCancel = document.getElementById('health-form-cancel');
|
||||
const formSave = document.getElementById('health-form-save');
|
||||
|
||||
// ---- Global health settings (retention, polling intervals, max entries, disk threshold) ----
|
||||
const HEALTH_SETTINGS_KEY = 'dashcaddy-health-settings';
|
||||
const HEALTH_DEFAULTS = { retentionDays: 30, pollingInterval: 60, statsPollingInterval: 30, maxEntriesPerService: 500, 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 statsIntervalInput = document.getElementById('health-setting-stats-interval');
|
||||
const maxEntriesInput = document.getElementById('health-setting-max-entries');
|
||||
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 (statsIntervalInput) statsIntervalInput.value = s.statsPollingInterval;
|
||||
if (maxEntriesInput) maxEntriesInput.value = s.maxEntriesPerService;
|
||||
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)),
|
||||
statsPollingInterval: Math.max(5, Math.min(3600, parseInt(statsIntervalInput?.value) || HEALTH_DEFAULTS.statsPollingInterval)),
|
||||
maxEntriesPerService: Math.max(10, Math.min(100000, parseInt(maxEntriesInput?.value) || HEALTH_DEFAULTS.maxEntriesPerService)),
|
||||
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) {
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
// Must match the 31 languages in language-selector.js and the backend i18n route.
|
||||
const SUPPORTED_LANGS = [
|
||||
'en', 'ar', 'bn', 'cs', 'da', 'de', 'el', 'es', 'fa', 'fi',
|
||||
'fr', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no',
|
||||
'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh',
|
||||
];
|
||||
const LANG_NAMES = {
|
||||
en: 'English', ar: 'العربية', bn: 'বাংলা', cs: 'Čeština', da: 'Dansk',
|
||||
de: 'Deutsch', el: 'Ελληνικά', es: 'Español', fa: 'فارسی', fi: 'Suomi',
|
||||
fr: 'Français', hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano',
|
||||
ja: '日本語', ko: '한국어', ms: 'Bahasa Melayu', nl: 'Nederlands', no: 'Norsk',
|
||||
pl: 'Polski', pt: 'Português', ro: 'Română', ru: 'Русский', sv: 'Svenska',
|
||||
th: 'ไทย', tr: 'Türkçe', uk: 'Українська', ur: 'اردو', vi: 'Tiếng Việt', zh: '中文',
|
||||
};
|
||||
// RTL languages need dir="rtl" on the document element. (No Hebrew per project policy.)
|
||||
const RTL_LANGS = new Set(['ar', 'fa', 'ur']);
|
||||
|
||||
// Validate the stored language — if it's invalid (old/corrupt), fall back to default.
|
||||
// Wrap in try/catch for environments where localStorage is disabled (private mode).
|
||||
function _readValidLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
let currentLang = _readValidLang();
|
||||
let translations = {};
|
||||
let loaded = false;
|
||||
// Monotonic token to guard against out-of-order async resolution.
|
||||
// Each setLanguage / loadTranslations call captures the current value; if it
|
||||
// changed by the time the fetch resolves, the result is discarded.
|
||||
let _langRequestId = 0;
|
||||
|
||||
async function loadTranslations(lang, reqId) {
|
||||
// reqId is the monotonic token incremented by the caller (setLanguage/init).
|
||||
// If not provided (direct API call), allocate one for backward compatibility.
|
||||
if (reqId === undefined) reqId = ++_langRequestId;
|
||||
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}`);
|
||||
// Guard against out-of-order resolution: if another loadTranslations
|
||||
// started after this one (or the user switched languages), discard.
|
||||
if (reqId !== _langRequestId) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (reqId !== _langRequestId) return; // double-check after second await
|
||||
translations = data.translations || {};
|
||||
loaded = true;
|
||||
} else {
|
||||
// HTTP error — clear stale translations so we don't show the wrong language
|
||||
translations = {};
|
||||
loaded = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Failed to load translations for', lang, e);
|
||||
if (reqId === _langRequestId) {
|
||||
translations = {};
|
||||
loaded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (currentLang === DEFAULT_LANG) return key;
|
||||
// If translations didn't load, fall back to the English key
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||
currentLang = lang;
|
||||
try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
|
||||
|
||||
// RTL handling — always set dir/lang explicitly so switching back to LTR works.
|
||||
const isRtl = RTL_LANGS.has(lang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
|
||||
loadTranslations(lang, reqId).then(() => {
|
||||
// Only apply if this is still the latest request.
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
function applyTranslations() {
|
||||
// Apply translations to elements with data-i18n attributes.
|
||||
// Always write the resolved value — when switching back to English or when a
|
||||
// key has no translation, this restores the original English text rather than
|
||||
// leaving the previous language's translated text visible.
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
// Apply to titles
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
}
|
||||
|
||||
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 = 'Select Language';
|
||||
|
||||
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: 160px; max-height: 320px; overflow-y: auto;';
|
||||
|
||||
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() {
|
||||
// Always set dir/lang explicitly — covers LTR reset and RTL setup.
|
||||
const isRtl = RTL_LANGS.has(currentLang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
function start() {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) {
|
||||
const reqId = ++_langRequestId;
|
||||
loadTranslations(currentLang, reqId).then(() => {
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||
|
||||
// Auto-init
|
||||
init();
|
||||
})();
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* DC-077: i18n Language Selector
|
||||
*
|
||||
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
||||
* the dashboard language. Supports all 31 backend languages with a searchable list.
|
||||
*
|
||||
* - Shows current language with flag emoji
|
||||
* - Persists selection to localStorage('dashcaddy-language')
|
||||
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
||||
* - Reloads the page on change so the new language takes effect
|
||||
* - Search filter for quickly finding a language in the 31-option list
|
||||
*
|
||||
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'dashcaddy-language';
|
||||
const CONFIG_ENDPOINT = '/api/v1/config';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' },
|
||||
{ code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' },
|
||||
{ code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' },
|
||||
{ code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' },
|
||||
{ code: 'da', flag: '🇩🇰', label: 'Danish', nativeLabel: 'Dansk' },
|
||||
{ code: 'de', flag: '🇩🇪', label: 'German', nativeLabel: 'Deutsch' },
|
||||
{ code: 'el', flag: '🇬🇷', label: 'Greek', nativeLabel: 'Ελληνικά' },
|
||||
{ code: 'es', flag: '🇪🇸', label: 'Spanish', nativeLabel: 'Español' },
|
||||
{ code: 'fa', flag: '🇮🇷', label: 'Persian', nativeLabel: 'فارسی' },
|
||||
{ code: 'fi', flag: '🇫🇮', label: 'Finnish', nativeLabel: 'Suomi' },
|
||||
{ code: 'fr', flag: '🇫🇷', label: 'French', nativeLabel: 'Français' },
|
||||
{ code: 'hi', flag: '🇮🇳', label: 'Hindi', nativeLabel: 'हिन्दी' },
|
||||
{ code: 'hu', flag: '🇭🇺', label: 'Hungarian', nativeLabel: 'Magyar' },
|
||||
{ code: 'id', flag: '🇮🇩', label: 'Indonesian', nativeLabel: 'Bahasa Indonesia' },
|
||||
{ code: 'it', flag: '🇮🇹', label: 'Italian', nativeLabel: 'Italiano' },
|
||||
{ code: 'ja', flag: '🇯🇵', label: 'Japanese', nativeLabel: '日本語' },
|
||||
{ code: 'ko', flag: '🇰🇷', label: 'Korean', nativeLabel: '한국어' },
|
||||
{ code: 'ms', flag: '🇲🇾', label: 'Malay', nativeLabel: 'Bahasa Melayu' },
|
||||
{ code: 'nl', flag: '🇳🇱', label: 'Dutch', nativeLabel: 'Nederlands' },
|
||||
{ code: 'no', flag: '🇳🇴', label: 'Norwegian', nativeLabel: 'Norsk' },
|
||||
{ code: 'pl', flag: '🇵🇱', label: 'Polish', nativeLabel: 'Polski' },
|
||||
{ code: 'pt', flag: '🇵🇹', label: 'Portuguese', nativeLabel: 'Português' },
|
||||
{ code: 'ro', flag: '🇷🇴', label: 'Romanian', nativeLabel: 'Română' },
|
||||
{ code: 'ru', flag: '🇷🇺', label: 'Russian', nativeLabel: 'Русский' },
|
||||
{ code: 'sv', flag: '🇸🇪', label: 'Swedish', nativeLabel: 'Svenska' },
|
||||
{ code: 'th', flag: '🇹🇭', label: 'Thai', nativeLabel: 'ไทย' },
|
||||
{ code: 'tr', flag: '🇹🇷', label: 'Turkish', nativeLabel: 'Türkçe' },
|
||||
{ code: 'uk', flag: '🇺🇦', label: 'Ukrainian', nativeLabel: 'Українська' },
|
||||
{ code: 'ur', flag: '🇵🇰', label: 'Urdu', nativeLabel: 'اردو' },
|
||||
{ code: 'vi', flag: '🇻🇳', label: 'Vietnamese', nativeLabel: 'Tiếng Việt' },
|
||||
{ code: 'zh', flag: '🇨🇳', label: 'Chinese', nativeLabel: '中文' },
|
||||
];
|
||||
|
||||
const SUPPORTED = LANGUAGES.map(l => l.code);
|
||||
|
||||
function getCurrentLanguage() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED.includes(stored)) return stored;
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function langMeta(code) {
|
||||
return LANGUAGES.find(l => l.code === code) || LANGUAGES[0];
|
||||
}
|
||||
|
||||
// ===== Inject styles once =====
|
||||
function injectStyles() {
|
||||
if (document.getElementById('dc-lang-selector-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'dc-lang-selector-styles';
|
||||
style.textContent = `
|
||||
.dc-lang-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.dc-lang-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
background: var(--card-base);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
}
|
||||
.dc-lang-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.dc-lang-btn .dc-lang-flag {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.dc-lang-btn .dc-lang-caret {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.dc-lang-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-base, #1e1e2e);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
padding: 4px;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
}
|
||||
.dc-lang-menu.open {
|
||||
display: flex;
|
||||
}
|
||||
.dc-lang-search {
|
||||
margin: 2px 0 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-base, #111);
|
||||
color: var(--fg);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dc-lang-search:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.dc-lang-list {
|
||||
overflow-y: auto;
|
||||
max-height: 280px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dc-lang-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
.dc-lang-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dc-lang-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg);
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-lang-option:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
.dc-lang-option.active {
|
||||
background: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dc-lang-option .dc-lang-flag {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.dc-lang-option .dc-lang-check {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dc-lang-option.active .dc-lang-check {
|
||||
opacity: 1;
|
||||
}
|
||||
.dc-lang-option.dc-lang-focus,
|
||||
.dc-lang-option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.dc-lang-label-sm {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
opacity: 0.8;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function buildMenu(current) {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'dc-lang-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
// Search input
|
||||
const search = document.createElement('input');
|
||||
search.type = 'text';
|
||||
search.className = 'dc-lang-search';
|
||||
search.placeholder = 'Search language…';
|
||||
search.setAttribute('aria-label', 'Search languages');
|
||||
search.autocomplete = 'off';
|
||||
|
||||
// Scrollable option list
|
||||
const list = document.createElement('div');
|
||||
list.className = 'dc-lang-list';
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
const opt = document.createElement('div');
|
||||
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
||||
opt.setAttribute('role', 'menuitemradio');
|
||||
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
||||
opt.setAttribute('tabindex', '-1');
|
||||
opt.dataset.lang = lang.code;
|
||||
opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase();
|
||||
opt.innerHTML =
|
||||
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
||||
'<span class="dc-lang-name">' + lang.nativeLabel +
|
||||
'<span style="opacity:0.5;font-size:0.8em;margin-left:6px;">' + lang.label + '</span>' +
|
||||
'</span>' +
|
||||
'<span class="dc-lang-check">✓</span>';
|
||||
list.appendChild(opt);
|
||||
}
|
||||
|
||||
// Filter logic — extracted so we can reset from the open handler
|
||||
function applyFilter(query) {
|
||||
var q = (query || '').toLowerCase().trim();
|
||||
list.querySelectorAll('.dc-lang-option').forEach(function (opt) {
|
||||
var match = !q || opt.dataset.search.indexOf(q) !== -1;
|
||||
opt.style.display = match ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
search.addEventListener('input', function () {
|
||||
applyFilter(this.value);
|
||||
});
|
||||
|
||||
// Prevent clicks on search from closing the menu
|
||||
search.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
|
||||
// Expose reset so init() can restore visibility when reopening
|
||||
menu._resetFilter = function () {
|
||||
search.value = '';
|
||||
applyFilter('');
|
||||
};
|
||||
|
||||
// ===== Keyboard navigation (Arrow Up/Down, Enter, Space) =====
|
||||
function getVisibleOptions() {
|
||||
return Array.from(list.querySelectorAll('.dc-lang-option')).filter(
|
||||
function (o) { return o.style.display !== 'none'; }
|
||||
);
|
||||
}
|
||||
|
||||
function focusOption(opt) {
|
||||
if (!opt) return;
|
||||
var visible = getVisibleOptions();
|
||||
visible.forEach(function (o) { o.classList.remove('dc-lang-focus'); });
|
||||
opt.classList.add('dc-lang-focus');
|
||||
opt.focus();
|
||||
}
|
||||
|
||||
search.addEventListener('keydown', function (e) {
|
||||
var visible = getVisibleOptions();
|
||||
if (visible.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
focusOption(visible[0]);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var active = list.querySelector('.dc-lang-option.active');
|
||||
if (active && active.style.display !== 'none') selectLanguage(active.dataset.lang);
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('keydown', function (e) {
|
||||
var visible = getVisibleOptions();
|
||||
var currentIdx = visible.indexOf(document.activeElement);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var next = visible[Math.min(currentIdx + 1, visible.length - 1)];
|
||||
focusOption(next);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (currentIdx === 0) {
|
||||
search.focus();
|
||||
} else {
|
||||
focusOption(visible[currentIdx - 1]);
|
||||
}
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
var opt = document.activeElement;
|
||||
if (opt && opt.classList.contains('dc-lang-option')) {
|
||||
selectLanguage(opt.dataset.lang);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menu.appendChild(search);
|
||||
menu.appendChild(list);
|
||||
return { menu: menu, search: search };
|
||||
}
|
||||
|
||||
async function selectLanguage(code) {
|
||||
if (!SUPPORTED.includes(code) || code === getCurrentLanguage()) return;
|
||||
// Persist locally immediately for instant reload
|
||||
localStorage.setItem(STORAGE_KEY, code);
|
||||
|
||||
// Best-effort backend sync — don't block the reload on failure
|
||||
try {
|
||||
if (typeof postJSON === 'function') {
|
||||
await postJSON(CONFIG_ENDPOINT, { language: code });
|
||||
} else if (typeof secureFetch === 'function') {
|
||||
await secureFetch(CONFIG_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: code }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: localStorage already holds the preference
|
||||
console.warn('[LanguageSelector] backend sync failed:', err);
|
||||
}
|
||||
|
||||
// Reload so the new language takes effect across the dashboard
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function init() {
|
||||
injectStyles();
|
||||
|
||||
const current = getCurrentLanguage();
|
||||
const meta = langMeta(current);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'dc-lang-wrap';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'dc-lang-btn';
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-label', 'Select language');
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.title = 'Switch language';
|
||||
btn.innerHTML =
|
||||
'<span class="dc-lang-flag">' + meta.flag + '</span>' +
|
||||
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
||||
'<span class="dc-lang-caret">▼</span>';
|
||||
|
||||
const built = buildMenu(current);
|
||||
const menu = built.menu;
|
||||
const searchInput = built.search;
|
||||
|
||||
// Small label beneath, matching the "Customize Theme" link style
|
||||
const label = document.createElement('span');
|
||||
label.className = 'dc-lang-label-sm';
|
||||
label.textContent = 'Language';
|
||||
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(label);
|
||||
wrap.appendChild(menu);
|
||||
|
||||
// Toggle menu open/closed
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = menu.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (isOpen) {
|
||||
// Reset filter: clear search text AND restore all hidden options
|
||||
if (typeof menu._resetFilter === 'function') {
|
||||
menu._resetFilter();
|
||||
}
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Option clicks (delegate to the list container)
|
||||
menu.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('.dc-lang-option');
|
||||
if (!opt) return;
|
||||
const code = opt.dataset.lang;
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
selectLanguage(code);
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrap.contains(e.target)) {
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape — return focus to the trigger button for accessibility
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Expose for programmatic use / testing
|
||||
window.DCLanguageSelector = {
|
||||
getCurrentLanguage,
|
||||
selectLanguage,
|
||||
LANGUAGES,
|
||||
STORAGE_KEY,
|
||||
};
|
||||
|
||||
// Auto-mount into the navbar next to the theme toggle.
|
||||
// The dashboard loads core.js (globals) before this deferred script, but the
|
||||
// navbar container is always present in the initial HTML.
|
||||
function mount() {
|
||||
const group = document.querySelector('.theme-toggle-group');
|
||||
if (group && group.parentNode) {
|
||||
// Insert immediately after the theme-toggle-group so it sits beside it
|
||||
group.parentNode.insertBefore(init(), group.nextSibling);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
|
||||
console.log('[LanguageSelector] Module loaded — current language:', getCurrentLanguage());
|
||||
})();
|
||||
@@ -1,184 +0,0 @@
|
||||
// ========== 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);
|
||||
}
|
||||
})();
|
||||
@@ -377,26 +377,8 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
};
|
||||
}
|
||||
|
||||
// Summary "Continue →" — advance to the disk-safety warning step
|
||||
const summaryNext = document.getElementById('setup-summary-next');
|
||||
if (summaryNext) {
|
||||
summaryNext.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-disk-safety');
|
||||
};
|
||||
}
|
||||
|
||||
// Disk-safety step navigation
|
||||
const diskSafetyBack = document.getElementById('setup-disk-safety-back');
|
||||
if (diskSafetyBack) {
|
||||
diskSafetyBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-summary');
|
||||
};
|
||||
}
|
||||
|
||||
// Finish setup button (now on the disk-safety step)
|
||||
const finishBtn = document.getElementById('setup-disk-safety-finish');
|
||||
// Finish setup button
|
||||
const finishBtn = document.getElementById('setup-finish');
|
||||
if (finishBtn) {
|
||||
finishBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
|
||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||