Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7967279b5b | ||
|
|
29eedb3515 | ||
|
|
89968f5485 | ||
|
|
2ada4694a2 | ||
|
|
7203130b02 | ||
|
|
70209cdf0c | ||
|
|
56c976a935 | ||
|
|
3b3c4f8b8e | ||
|
|
571b86b660 | ||
|
|
fabda78929 | ||
|
|
7f97ff4a7f | ||
|
|
234df5038c | ||
|
|
9d085ebf94 | ||
|
|
0e9370891f | ||
|
|
def1a6a9f3 | ||
|
|
1e1b50d61c | ||
|
|
aa607a9230 | ||
|
|
6b9882ca04 | ||
|
|
e2239fbcd2 | ||
|
|
1bee77d5fb | ||
|
|
cc4d9dea10 | ||
|
|
5d1fdb86eb |
@@ -91,15 +91,15 @@ describe('HealthChecker', () => {
|
||||
describe('getBackoffInterval', () => {
|
||||
it('returns base interval when no failures', () => {
|
||||
const interval = healthChecker.getBackoffInterval('svc1');
|
||||
expect(interval).toBe(30000); // CHECK_INTERVAL default
|
||||
expect(interval).toBe(60000); // CHECK_INTERVAL default (60s)
|
||||
});
|
||||
|
||||
it('doubles interval per consecutive failure', () => {
|
||||
healthChecker.consecutiveFailures.set('svc1', 1);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(60000);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(120000);
|
||||
|
||||
healthChecker.consecutiveFailures.set('svc1', 2);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(120000);
|
||||
expect(healthChecker.getBackoffInterval('svc1')).toBe(240000);
|
||||
});
|
||||
|
||||
it('caps at MAX_CHECK_INTERVAL', () => {
|
||||
|
||||
@@ -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', 'klingon')).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('xx')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,61 +81,14 @@ describe('DC-077: i18n system', () => {
|
||||
});
|
||||
|
||||
it('defaults to English for unsupported languages', () => {
|
||||
expect(i18n.detectLanguage('klingon-KR,klingon;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('xx-XX,xx;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('klingon-KL,klingon;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', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Root-level /metrics endpoint tests — DC-097b
|
||||
*
|
||||
* Verifies that:
|
||||
* - GET /metrics returns Prometheus text format (not JSON, not HTML)
|
||||
* - The Content-Type is text/plain with Prometheus version
|
||||
* - The response includes HELP/TYPE annotations and metric names
|
||||
* - The endpoint is listed in PUBLIC_ROUTES (no auth required)
|
||||
* - The endpoint is in the rate-limiter skip list
|
||||
*
|
||||
* Documentation tells users to scrape /metrics (the Prometheus convention),
|
||||
* but the route previously only existed at /api/v1/metrics/prometheus. The
|
||||
* root-level alias makes doc examples work without modification.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MIDDLEWARE_PATH = path.join(__dirname, '../src/utilities/middleware.js');
|
||||
const APP_PATH = path.join(__dirname, '../src/app.js');
|
||||
|
||||
describe('Root-level /metrics endpoint — DC-097b', () => {
|
||||
describe('PUBLIC_ROUTES includes /metrics', () => {
|
||||
let mwSource;
|
||||
beforeAll(() => {
|
||||
mwSource = fs.readFileSync(MIDDLEWARE_PATH, 'utf8');
|
||||
});
|
||||
|
||||
test('/metrics is in PUBLIC_ROUTES', () => {
|
||||
// Match the route entry: { path: '/metrics', ... method: 'GET' }
|
||||
expect(mwSource).toMatch(/['"]\/metrics['"]/);
|
||||
});
|
||||
|
||||
test('/metrics is in the rate-limiter skip list', () => {
|
||||
expect(mwSource).toMatch(/req\.path\s*===\s*['"]\/metrics['"]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('app.js registers GET /metrics', () => {
|
||||
let appSource;
|
||||
beforeAll(() => {
|
||||
appSource = fs.readFileSync(APP_PATH, 'utf8');
|
||||
});
|
||||
|
||||
test('app.get("/metrics", ...) is registered', () => {
|
||||
expect(appSource).toMatch(/app\.get\(\s*['"]\/metrics['"]/);
|
||||
});
|
||||
|
||||
test('/metrics handler sets Prometheus Content-Type', () => {
|
||||
// The handler should set Content-Type to text/plain with prometheus version
|
||||
expect(appSource).toMatch(/text\/plain.*version=0\.0\.4/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parity with /api/v1/metrics/prometheus', () => {
|
||||
let appSource;
|
||||
beforeAll(() => {
|
||||
appSource = fs.readFileSync(APP_PATH, 'utf8');
|
||||
});
|
||||
|
||||
test('both endpoints call metrics.toPrometheus()', () => {
|
||||
const matches = appSource.match(/metrics\.toPrometheus\(\)/g);
|
||||
expect(matches).toBeTruthy();
|
||||
// At least two call sites: /api/v1/metrics/prometheus and /metrics
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,13 @@ function createI18nApp() {
|
||||
}
|
||||
|
||||
describe('DC-077: i18n Routes', () => {
|
||||
it('GET /i18n/languages returns 31 languages', async () => {
|
||||
it('GET /i18n/languages returns supported 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.length).toBeGreaterThanOrEqual(5);
|
||||
expect(res.body.default).toBe('en');
|
||||
});
|
||||
|
||||
|
||||
@@ -10,18 +10,12 @@ module.exports = function() {
|
||||
|
||||
// GET /api/v1/i18n/languages — list supported languages
|
||||
router.get('/i18n/languages', (req, res) => {
|
||||
const meta = i18n.getAllLanguages();
|
||||
ok(res, {
|
||||
languages: i18n.getSupportedLanguages().map(code => ({
|
||||
code,
|
||||
name: {
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
ar: 'العربية',
|
||||
}[code] || code,
|
||||
rtl: code === 'ar',
|
||||
})),
|
||||
languages: i18n.getSupportedLanguages().map(code => {
|
||||
const m = meta[code] || {};
|
||||
return { code, name: m.name || code, flag: m.flag || '🌐', rtl: !!m.rtl };
|
||||
}),
|
||||
default: i18n.DEFAULT_LANGUAGE,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ process.on('uncaughtException', (error) => {
|
||||
(async () => {
|
||||
try {
|
||||
// Create and configure Express app
|
||||
const { app, log, config, licenseManager } = await createApp();
|
||||
const { app, log, config, licenseManager, workflowEngine: appWorkflowEngine } = await createApp();
|
||||
|
||||
// Load license
|
||||
await licenseManager.load();
|
||||
@@ -112,11 +112,13 @@ process.on('uncaughtException', (error) => {
|
||||
try { logDigest = require('./src/security/log-digest'); } catch { /* optional */ }
|
||||
try { bundledWorkflows = require('./src/recipes/bundled-workflows'); } catch { /* optional */ }
|
||||
|
||||
// Initialize workflow engine if bundled-workflows is available
|
||||
// NOTE: createApp() already initializes the workflow engine in src/app.js
|
||||
// This block is kept for backward compat with entry points that don't use createApp()
|
||||
let workflowEngine = null;
|
||||
if (bundledWorkflows) {
|
||||
// Reuse the workflow engine created by createApp() to avoid duplicate
|
||||
// scheduled jobs (DC-CPU: two WorkflowEngine instances each scheduled
|
||||
// health-check-on-interval, causing every periodic workflow to fire
|
||||
// twice and double the polling load). Only create one if createApp()
|
||||
// didn't (e.g. legacy entry points without docker).
|
||||
let workflowEngine = appWorkflowEngine || null;
|
||||
if (!workflowEngine && bundledWorkflows) {
|
||||
try {
|
||||
const { fetchT } = require('./src/utils/http');
|
||||
const { WorkflowEngine } = bundledWorkflows;
|
||||
@@ -134,7 +136,7 @@ process.on('uncaughtException', (error) => {
|
||||
servicesStateManager
|
||||
};
|
||||
workflowEngine = new WorkflowEngine(workflowCtx);
|
||||
log.info('server', 'Workflow engine initialized');
|
||||
log.info('server', 'Workflow engine initialized (fallback)');
|
||||
} catch (err) {
|
||||
log.error('server', 'Workflow engine failed to initialize', { error: err.message });
|
||||
}
|
||||
|
||||
@@ -129,8 +129,12 @@ async function createApp() {
|
||||
});
|
||||
// Disable x-powered-by header for security (don't advertise framework)
|
||||
app.disable('x-powered-by');
|
||||
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
|
||||
app.set('trust proxy', 1);
|
||||
// Trust reverse proxies on loopback AND Docker bridge networks so req.ip
|
||||
// reflects the real client IP from X-Forwarded-For. 'loopback' covers
|
||||
// bare-metal Caddy→node deployments; the Docker CIDRs cover containerised
|
||||
// deployments where Caddy connects via the bridge gateway. External IPs
|
||||
// cannot appear in this list, preventing X-Forwarded-For spoofing.
|
||||
app.set('trust proxy', ['loopback', '172.16.0.0/12', '10.0.0.0/8']);
|
||||
|
||||
// Initialize logging
|
||||
const log = createLogger(config.LOG_LEVEL);
|
||||
@@ -460,7 +464,7 @@ async function createApp() {
|
||||
// Initialize config drift detector
|
||||
const driftDetector = new ConfigDriftDetector(ctx);
|
||||
ctx.driftDetector = driftDetector;
|
||||
driftDetector.startPolling(300000); // 5 min
|
||||
driftDetector.startPolling(600000); // 10 min (was 5 min — docker inspect per container is CPU heavy)
|
||||
log.info('app', 'Config drift detector initialized');
|
||||
|
||||
// Initialize SSL monitor
|
||||
@@ -472,7 +476,7 @@ async function createApp() {
|
||||
// Initialize disk space monitor (disk budget + auto-cleanup)
|
||||
const diskSpaceMonitor = new DiskSpaceMonitor({ log, config: ctx.siteConfig });
|
||||
ctx.diskSpaceMonitor = diskSpaceMonitor;
|
||||
diskSpaceMonitor.start(600000); // 10 min
|
||||
diskSpaceMonitor.start(1800000); // 30 min (was 10 min — docker system df is CPU/IO heavy)
|
||||
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
@@ -928,6 +932,19 @@ async function createApp() {
|
||||
app.get('/health/ready', readinessHandler);
|
||||
app.get('/readyz', readinessHandler);
|
||||
|
||||
// ===========================================================================
|
||||
// Prometheus root-level /metrics endpoint
|
||||
//
|
||||
// The API exposes Prometheus text-format metrics at /api/v1/metrics/prometheus,
|
||||
// but documentation, dashboards, and users expect to scrape the conventional
|
||||
// /metrics path. Expose the same output at root level so Prometheus configs
|
||||
// from the docs work without modification.
|
||||
// ===========================================================================
|
||||
app.get('/metrics', (req, res) => {
|
||||
res.set('Content-Type', 'text/plain; version=0.0.4');
|
||||
res.send(metrics.toPrometheus());
|
||||
});
|
||||
|
||||
// Lightweight probe endpoint
|
||||
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
||||
const id = req.params.id;
|
||||
@@ -1105,7 +1122,7 @@ async function createApp() {
|
||||
app.use('/api', notFoundHandler);
|
||||
app.use(errorMiddleware);
|
||||
|
||||
return { app, log, config: config.siteConfig, licenseManager };
|
||||
return { app, log, config: config.siteConfig, licenseManager, workflowEngine: ctx.workflowEngine };
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
|
||||
@@ -1765,9 +1765,10 @@ 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",
|
||||
name: "Vintage Radio",
|
||||
description: "Tune a beautiful retro radio through real internet stations (SomaFM, KEXP, BBC, Radio Paradise, and more)",
|
||||
icon: "📻",
|
||||
logo: "/assets/vintage-radio.png",
|
||||
category: "Media",
|
||||
popularity: 72,
|
||||
difficulty: "Easy",
|
||||
@@ -1783,23 +1784,17 @@ const APP_TEMPLATES = {
|
||||
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"
|
||||
},
|
||||
staticSite: true,
|
||||
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)"
|
||||
"Vintage wooden-cabinet radio UI with analog dial",
|
||||
"Curated list of real public internet-radio streams",
|
||||
"Smooth tuner animation with frequency scanning",
|
||||
"Live VU meter and station-name display",
|
||||
"Add your own streams by editing stations.json"
|
||||
],
|
||||
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)",
|
||||
"Drag the dial or click a station card to tune in",
|
||||
"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"]
|
||||
@@ -2582,7 +2577,386 @@ const APP_TEMPLATES = {
|
||||
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"authelia": {
|
||||
name: "Authelia",
|
||||
description: "Single sign-on portal and authentication gateway for web apps",
|
||||
icon: "\u{1F6E1}\u{FE0F}",
|
||||
category: "Security",
|
||||
popularity: 82,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "authelia/authelia:latest",
|
||||
ports: ["{{PORT}}:9091"],
|
||||
volumes: ["/opt/authelia/config:/config"],
|
||||
environment: {
|
||||
"AUTHELIA_STORAGE": "local",
|
||||
"AUTHELIA_STORAGE_LOCAL_PATH": "/config/db.sqlite3"
|
||||
}
|
||||
},
|
||||
subdomain: "authelia",
|
||||
defaultPort: 9091,
|
||||
healthCheck: "/api/health",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: [
|
||||
"Edit /opt/authelia/config/configuration.yml with your domain and settings",
|
||||
"Configure users in /opt/authelia/config/users_database.yml",
|
||||
"Set up Caddy forward_auth to protect your services behind Authelia",
|
||||
"Configure 2FA (TOTP, WebAuthn) in the Authelia portal"
|
||||
],
|
||||
dashcaddyIntegration: {
|
||||
ssoCompatible: true,
|
||||
forwardAuth: true,
|
||||
caddySnippet: "forward_auth authelia.<domain>:9091 { uri /api/verify?rd=https://authelia.<domain> copy_headers Remote-User Remote-Groups Remote-Name Remote-Email }"
|
||||
}
|
||||
},
|
||||
|
||||
"keycloak": {
|
||||
name: "Keycloak",
|
||||
description: "Open source identity and access management with SSO, OIDC, and SAML",
|
||||
icon: "\u{1F511}",
|
||||
category: "Security",
|
||||
popularity: 85,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "quay.io/keycloak/keycloak:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: ["/opt/keycloak/data:/opt/keycloak/data"],
|
||||
environment: {
|
||||
"KEYCLOAK_ADMIN": "admin",
|
||||
"KEYCLOAK_ADMIN_PASSWORD": "{{GENERATED_SECRET}}",
|
||||
"KC_DB": "dev-file",
|
||||
"KC_HOSTNAME": "{{SUBDOMAIN}}.{{TLD}}"
|
||||
},
|
||||
command: ["start-dev"]
|
||||
},
|
||||
subdomain: "keycloak",
|
||||
defaultPort: 8090,
|
||||
healthCheck: "/health/ready",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Log in with admin / generated password",
|
||||
"Create a realm for your applications",
|
||||
"Configure OIDC clients for each app you want to protect",
|
||||
"Set up identity providers (Google, GitHub, etc.) for social login"
|
||||
],
|
||||
secrets: [{
|
||||
envVar: "GENERATED_SECRET",
|
||||
label: "Keycloak Admin Password",
|
||||
description: "Initial admin password",
|
||||
type: "password",
|
||||
required: true,
|
||||
generate: "alphanumeric",
|
||||
length: 32
|
||||
}]
|
||||
},
|
||||
|
||||
"vikunja": {
|
||||
name: "Vikunja",
|
||||
description: "Self-hosted to-do app with lists, kanban boards, and teams",
|
||||
icon: "\u2705",
|
||||
category: "Productivity",
|
||||
popularity: 72,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "vikunja/vikunja:latest",
|
||||
ports: ["{{PORT}}:3456"],
|
||||
volumes: ["/opt/vikunja/files:/app/vikunja/files", "/opt/vikunja/db:/app/vikunja/database"],
|
||||
environment: {
|
||||
"VIKUNJA_SERVICE_JWTSECRET": "{{GENERATED_SECRET}}",
|
||||
"VIKUNJA_SERVICE_FRONTENDURL": "https://{{SUBDOMAIN}}.{{TLD}}"
|
||||
}
|
||||
},
|
||||
subdomain: "vikunja",
|
||||
defaultPort: 3456,
|
||||
healthCheck: "/api/v1/info",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Register the first user account", "Create projects and tasks", "Set up teams for shared task management"],
|
||||
secrets: [{
|
||||
envVar: "GENERATED_SECRET",
|
||||
label: "JWT Secret",
|
||||
description: "Secret for signing auth tokens",
|
||||
type: "password",
|
||||
required: true,
|
||||
generate: "alphanumeric",
|
||||
length: 48
|
||||
}]
|
||||
},
|
||||
|
||||
"openproject": {
|
||||
name: "OpenProject",
|
||||
description: "Project management with Gantt charts, time tracking, and Scrum boards",
|
||||
icon: "\u{1F4CB}",
|
||||
category: "Productivity",
|
||||
popularity: 75,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "openproject/openproject:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: ["/opt/openproject/pgdata:/var/openproject/pgdata", "/opt/openproject/assets:/var/openproject/assets"],
|
||||
environment: { "OPENPROJECT_HOST__NAME": "{{SUBDOMAIN}}.{{TLD}}", "OPENPROJECT_HTTPS": "true" }
|
||||
},
|
||||
subdomain: "projects",
|
||||
defaultPort: 8091,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Complete the initial setup wizard", "Create your first project", "Add team members and configure permissions"]
|
||||
},
|
||||
|
||||
"plane": {
|
||||
name: "Plane",
|
||||
description: "Open-source Jira alternative with issues, cycles, and modules",
|
||||
icon: "\u2708\uFE0F",
|
||||
category: "Productivity",
|
||||
popularity: 70,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "makeplane/plane-frontend:latest",
|
||||
ports: ["{{PORT}}:3000"],
|
||||
volumes: ["/opt/plane/data:/app/data"],
|
||||
environment: { "NEXT_PUBLIC_API_BASE_URL": "https://{{SUBDOMAIN}}.{{TLD}}/api" }
|
||||
},
|
||||
subdomain: "plane",
|
||||
defaultPort: 8092,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Plane requires multiple containers - use the recipe for full stack deploy", "Create an organization and invite team members", "Set up cycles and modules for project tracking"]
|
||||
},
|
||||
|
||||
"hedgedoc": {
|
||||
name: "HedgeDoc",
|
||||
description: "Collaborative markdown editor with real-time editing",
|
||||
icon: "\u{1F4DD}",
|
||||
category: "Productivity",
|
||||
popularity: 68,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "quay.io/hedgedoc/hedgedoc:latest",
|
||||
ports: ["{{PORT}}:3000"],
|
||||
volumes: ["/opt/hedgedoc/uploads:/hedgedoc/public/uploads"],
|
||||
environment: {
|
||||
"CMD_DOMAIN": "{{SUBDOMAIN}}.{{TLD}}",
|
||||
"CMD_URL_ADDPORT": "false",
|
||||
"CMD_PROTOCOL_USESSL": "true",
|
||||
"CMD_DB_URL": "sqlite:/hedgedoc/database.sqlite"
|
||||
}
|
||||
},
|
||||
subdomain: "notes",
|
||||
defaultPort: 8093,
|
||||
healthCheck: "/status",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Register an account or use guest mode", "Create collaborative markdown documents", "Share links for real-time co-editing"]
|
||||
},
|
||||
|
||||
"yacht": {
|
||||
name: "Yacht",
|
||||
description: "Web-based Docker container manager with template support",
|
||||
icon: "\u26F5",
|
||||
category: "Management",
|
||||
popularity: 65,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "selfhostedpro/yacht:latest",
|
||||
ports: ["{{PORT}}:8000"],
|
||||
volumes: ["/opt/yacht/config:/config", "/var/run/docker.sock:/var/run/docker.sock"],
|
||||
environment: { "PUID": "1000", "PGID": "1000" }
|
||||
},
|
||||
subdomain: "yacht",
|
||||
defaultPort: 8094,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'strip',
|
||||
setupInstructions: ["Set up admin account on first launch", "Add compose templates for one-click deployments", "Manage containers through the web interface"]
|
||||
},
|
||||
|
||||
"openvpn": {
|
||||
name: "OpenVPN",
|
||||
description: "Virtual private network server for secure remote access",
|
||||
icon: "\u{1F512}",
|
||||
category: "Security",
|
||||
popularity: 78,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "kylemanna/openvpn:latest",
|
||||
ports: ["{{PORT}}:1194/udp"],
|
||||
volumes: ["/opt/openvpn:/etc/openvpn"],
|
||||
environment: {},
|
||||
cap_add: ["NET_ADMIN"]
|
||||
},
|
||||
subdomain: "openvpn",
|
||||
defaultPort: 1194,
|
||||
healthCheck: null,
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Initialize the PKI: docker exec openvpn ovpn_initpki", "Generate client certificates for each device", "Download client configs and import into OpenVPN client"]
|
||||
},
|
||||
|
||||
"forgejo": {
|
||||
name: "Forgejo",
|
||||
description: "Self-hosted Git service - soft fork of Gitea with enhanced features",
|
||||
icon: "\u{1F527}",
|
||||
category: "Development",
|
||||
popularity: 73,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "codeberg.org/forgejo/forgejo:latest",
|
||||
ports: ["{{PORT}}:3000", "2222:22"],
|
||||
volumes: ["/opt/forgejo/data:/data", "/etc/localtime:/etc/localtime:ro"],
|
||||
environment: { "USER_UID": "1000", "USER_GID": "1000" }
|
||||
},
|
||||
subdomain: "forgejo",
|
||||
defaultPort: 8095,
|
||||
healthCheck: "/api/v1/version",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Configure database on first run", "Create admin account and disable open registration", "Migrate repos from GitHub/GitLab via the built-in migration tool", "Set up Actions for CI/CD pipelines"]
|
||||
},
|
||||
|
||||
"gitlab": {
|
||||
name: "GitLab",
|
||||
description: "Complete DevOps platform with Git, CI/CD, and project management",
|
||||
icon: "\u{1F99A}",
|
||||
category: "Development",
|
||||
popularity: 88,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "gitlab/gitlab-ce:latest",
|
||||
ports: ["{{PORT}}:80", "8443:443", "2223:22"],
|
||||
volumes: ["/opt/gitlab/config:/etc/gitlab", "/opt/gitlab/logs:/var/log/gitlab", "/opt/gitlab/data:/var/opt/gitlab"],
|
||||
environment: { "GITLAB_OMNIBUS_CONFIG": "external_url https://{{SUBDOMAIN}}.{{TLD}}" },
|
||||
shm_size: "256m"
|
||||
},
|
||||
subdomain: "gitlab",
|
||||
defaultPort: 8096,
|
||||
healthCheck: "/-/health",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Requires at least 4GB RAM and 2 CPU cores", "Wait 5-10 minutes for first boot", "Set root password on first login", "Configure runners for CI/CD pipelines"],
|
||||
resourceWarning: { minMemoryMB: 4096, minCpuCores: 2, message: "GitLab requires at least 4GB RAM. Deploying on a smaller server may cause instability." }
|
||||
},
|
||||
|
||||
"prometheus": {
|
||||
name: "Prometheus",
|
||||
description: "Monitoring system with time-series database and alerting",
|
||||
icon: "\u{1F525}",
|
||||
category: "Monitoring",
|
||||
popularity: 85,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "prom/prometheus:latest",
|
||||
ports: ["{{PORT}}:9090"],
|
||||
volumes: ["/opt/prometheus/data:/prometheus", "/opt/prometheus/config:/etc/prometheus"],
|
||||
environment: {},
|
||||
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle"]
|
||||
},
|
||||
subdomain: "prometheus",
|
||||
defaultPort: 9090,
|
||||
healthCheck: "/-/healthy",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Edit /opt/prometheus/config/prometheus.yml to add scrape targets", "Add DashCaddy /api/v1/metrics/prometheus as a scrape target", "Pair with Grafana for dashboards", "Configure alert rules"]
|
||||
},
|
||||
|
||||
"netdata": {
|
||||
name: "Netdata",
|
||||
description: "Real-time system metrics with zero configuration monitoring",
|
||||
icon: "\u{1F4C8}",
|
||||
category: "Monitoring",
|
||||
popularity: 82,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "netdata/netdata:latest",
|
||||
ports: ["{{PORT}}:19999"],
|
||||
volumes: ["/opt/netdata/config:/etc/netdata", "/opt/netdata/lib:/var/lib/netdata", "/opt/netdata/cache:/var/cache/netdata", "/proc:/host/proc:ro", "/sys:/host/sys:ro", "/etc/os-release:/host/etc/os-release:ro"],
|
||||
environment: { "NETDATA_CLAIM_URL": "https://app.netdata.cloud" },
|
||||
cap_add: ["SYS_PTRACE"],
|
||||
security_opt: ["apparmor:unconfined"]
|
||||
},
|
||||
subdomain: "netdata",
|
||||
defaultPort: 19999,
|
||||
healthCheck: "/api/v1/info",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Dashboard is immediately available with zero configuration", "Monitor CPU, RAM, disk, network, Docker in real-time", "Configure alarms for resource thresholds", "Optional: claim to Netdata Cloud for centralized monitoring"]
|
||||
},
|
||||
|
||||
"kibana": {
|
||||
name: "Kibana",
|
||||
description: "Visualization dashboard for Elasticsearch data analytics",
|
||||
icon: "\u{1F4C9}",
|
||||
category: "Monitoring",
|
||||
popularity: 70,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "kibana:8.12.0",
|
||||
ports: ["{{PORT}}:5601"],
|
||||
volumes: ["/opt/kibana/config:/usr/share/kibana/config"],
|
||||
environment: { "ELASTICSEARCH_HOSTS": "http://elasticsearch:9200" }
|
||||
},
|
||||
subdomain: "kibana",
|
||||
defaultPort: 5601,
|
||||
healthCheck: "/api/status",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Requires an Elasticsearch instance running first", "Consider deploying via the ELK Stack recipe", "Create index patterns for your data", "Build dashboards and visualizations"]
|
||||
},
|
||||
|
||||
"openhab": {
|
||||
name: "OpenHAB",
|
||||
description: "Open source home automation platform with broad device support",
|
||||
icon: "\u2699\uFE0F",
|
||||
category: "Home Automation",
|
||||
popularity: 72,
|
||||
difficulty: "Intermediate",
|
||||
docker: {
|
||||
image: "openhab/openhab:latest",
|
||||
ports: ["{{PORT}}:8080", "8444:8443"],
|
||||
volumes: ["/opt/openhab/conf:/openhab/conf", "/opt/openhab/userdata:/openhab/userdata", "/opt/openhab/addons:/openhab/addons", "/etc/localtime:/etc/localtime:ro"],
|
||||
environment: { "OPENHAB_HTTP_PORT": "8080", "OPENHAB_HTTPS_PORT": "8443", "EXTRA_JAVA_OPTS": "-Duser.timezone={{TIMEZONE}}" }
|
||||
},
|
||||
subdomain: "openhab",
|
||||
defaultPort: 8097,
|
||||
healthCheck: "/start/index",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Choose your preferred package (Standard, Demo, or Minimal)", "Install bindings for your smart home devices", "Create items, sitemaps, and rules", "Access the Paper UI for configuration"]
|
||||
},
|
||||
|
||||
"bitwarden": {
|
||||
name: "Bitwarden",
|
||||
description: "Official Bitwarden password manager server (unified self-hosted)",
|
||||
icon: "\u{1F510}",
|
||||
category: "Security",
|
||||
popularity: 80,
|
||||
difficulty: "Advanced",
|
||||
docker: {
|
||||
image: "bitwarden/self-host:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: ["/opt/bitwarden/data:/etc/bitwarden"],
|
||||
environment: { "BW_DOMAIN": "{{SUBDOMAIN}}.{{TLD}}" }
|
||||
},
|
||||
subdomain: "bitwarden",
|
||||
defaultPort: 8098,
|
||||
healthCheck: "/health",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: ["Complete the Bitwarden unified installation", "Generate installation ID and key at bitwarden.com/host", "Configure SMTP for email verification", "Note: Vaultwarden is recommended for smaller deployments"],
|
||||
resourceWarning: { minMemoryMB: 2048, message: "Official Bitwarden requires 2GB+ RAM. For lightweight deployments, use Vaultwarden instead." }
|
||||
},
|
||||
|
||||
"subsonic": {
|
||||
name: "Subsonic",
|
||||
description: "Web-based media streamer for music with wide client support",
|
||||
icon: "\u{1F3B5}",
|
||||
category: "Media",
|
||||
popularity: 60,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "linuxserver/subsonic:latest",
|
||||
ports: ["{{PORT}}:4040"],
|
||||
volumes: ["/opt/subsonic/config:/config", "/opt/subsonic/music:/music", "/opt/subsonic/podcasts:/podcasts", "/opt/subsonic/playlists:/playlists"],
|
||||
environment: { "PUID": "1000", "PGID": "1000", "TZ": "{{TIMEZONE}}" }
|
||||
},
|
||||
subdomain: "subsonic",
|
||||
defaultPort: 4040,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'native',
|
||||
setupInstructions: ["Set admin password on first launch", "Add music folders in Settings", "Install Subsonic-compatible apps on your devices", "Note: Airsonic Advanced is also available as a fully open-source alternative"],
|
||||
mediaMount: { required: true, containerPath: "/music", label: "Music Library", description: "Folder containing your music files", defaultPath: "/media/music" }
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
// Template categories for organization
|
||||
|
||||
@@ -235,9 +235,16 @@ class SelfUpdater extends EventEmitter {
|
||||
this.status = 'downloading';
|
||||
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
|
||||
|
||||
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
|
||||
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
|
||||
const mirrorUrl = `${this.config.mirrorUrl}/${remoteInfo.tarball}`;
|
||||
// Resolve the tarball filename. Older version.json payloads provide a
|
||||
// `tarball` field; newer ones only provide a full `url`. Derive the
|
||||
// filename from whichever is present so path.join() never receives
|
||||
// undefined (which previously crashed the auto-updater every cycle).
|
||||
const tarballName = remoteInfo.tarball
|
||||
|| (remoteInfo.url ? remoteInfo.url.split('/').pop() : null)
|
||||
|| `dashcaddy-${remoteInfo.version || 'unknown'}.tar.gz`;
|
||||
const tarballPath = path.join(this.config.updatesDir || '.', tarballName);
|
||||
const primaryUrl = remoteInfo.url || `${this.config.updateUrl}/${tarballName}`;
|
||||
const mirrorUrl = `${this.config.mirrorUrl}/${tarballName}`;
|
||||
try {
|
||||
await this._downloadFile(primaryUrl, tarballPath);
|
||||
} catch (dlErr) {
|
||||
@@ -564,8 +571,14 @@ class SelfUpdater extends EventEmitter {
|
||||
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
|
||||
if (versionCompare < 0) return true;
|
||||
if (versionCompare > 0) return false;
|
||||
// Same version — check commit hash
|
||||
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
|
||||
// Same version. A different commit hash alone is NOT enough to trigger an
|
||||
// auto-update — that caused an endless update loop where every 30-minute
|
||||
// check saw a commit mismatch, attempted applyUpdate(), and crashed
|
||||
// (tarball field absent in version.json). Only treat same-version as
|
||||
// newer when the release explicitly opts in via a boolean flag.
|
||||
if (remote.commit && local.commit && remote.commit !== local.commit) {
|
||||
return remote.forceUpdate === true || remote.sameVersionUpdate === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const MAX_STATS_PER_CONTAINER = parseInt(process.env.STATS_MAX_ENTRIES || '500',
|
||||
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
|
||||
const MONITORING_INTERVAL = parseInt(process.env.MONITORING_INTERVAL || '10000', 10); // 10 seconds
|
||||
const MONITORING_INTERVAL = parseInt(process.env.MONITORING_INTERVAL || '30000', 10); // 30 seconds (was 10s — docker stats per container is CPU heavy)
|
||||
const ROLLUP_HOURLY_INTERVAL = parseInt(process.env.ROLLUP_HOURLY_INTERVAL || String(60 * 60 * 1000), 10); // 1h
|
||||
const ROLLUP_DAILY_INTERVAL = parseInt(process.env.ROLLUP_DAILY_INTERVAL || String(24 * 60 * 60 * 1000), 10); // 24h
|
||||
|
||||
|
||||
@@ -123,7 +123,19 @@ class DiskSpaceMonitor extends EventEmitter {
|
||||
if (status === 'critical' || status === 'aggressive') {
|
||||
this.emit('budget-exceeded', snapshot);
|
||||
if (this.diskConfig.autoCleanup) {
|
||||
this.performCleanup(status === 'critical' ? 'aggressive' : 'standard').catch(() => {});
|
||||
// Cooldown: only run the expensive docker prune operations at most
|
||||
// once per hour. Without this, every 10-minute snapshot that found
|
||||
// the budget exceeded would kick off another full prune sweep
|
||||
// (docker image prune -a, volume prune, builder prune...) even when
|
||||
// the previous sweep reclaimed 0 bytes — a major CPU/IO drain.
|
||||
const now = Date.now();
|
||||
const MIN_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const lastCleanupMs = this.lastCleanup?.completedAt
|
||||
? new Date(this.lastCleanup.completedAt).getTime()
|
||||
: 0;
|
||||
if (now - lastCleanupMs >= MIN_CLEANUP_INTERVAL_MS) {
|
||||
this.performCleanup(status === 'critical' ? 'aggressive' : 'standard').catch(() => {});
|
||||
}
|
||||
}
|
||||
} else if (status === 'warning') {
|
||||
this.emit('budget-warning', snapshot);
|
||||
|
||||
@@ -28,7 +28,7 @@ const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_
|
||||
// the new location.
|
||||
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 CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '60000', 10); // 60 seconds (was 30s — reduce CPU overhead on busy hosts)
|
||||
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);
|
||||
|
||||
@@ -529,40 +529,23 @@ function isSupported(lang) { return SUPPORTED_LANGUAGES.indexOf(lang) >= 0; }
|
||||
function detectLanguage(acceptLanguage) {
|
||||
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
||||
var parts = acceptLanguage.split(',');
|
||||
var entries = [];
|
||||
var parsed = [];
|
||||
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 raw = parts[i].trim();
|
||||
var langParts = raw.split(';');
|
||||
var code = langParts[0].split('-')[0].toLowerCase();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
for (var j = 1; j < langParts.length; j++) {
|
||||
var kv = langParts[j].trim().split('=');
|
||||
if (kv[0] === 'q' && kv[1]) q = parseFloat(kv[1]);
|
||||
}
|
||||
entries.push({ code: code, q: q, order: i });
|
||||
parsed.push({ code: code, q: isNaN(q) ? 1.0 : q });
|
||||
}
|
||||
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;
|
||||
// Sort by q-value descending so the highest-priority language is tried first
|
||||
parsed.sort(function (a, b) { return b.q - a.q; });
|
||||
for (var k = 0; k < parsed.length; k++) {
|
||||
if (isSupported(parsed[k].code)) return parsed[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;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,11 @@ module.exports = function configureMiddleware(app, {
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" }
|
||||
}));
|
||||
|
||||
// ── Trust proxy (one hop — Caddy) ──
|
||||
app.set('trust proxy', 1);
|
||||
// ── Trust proxy (loopback + Docker bridge) ──
|
||||
// Only trust proxy headers from loopback and private network addresses.
|
||||
// This prevents external IPs from spoofing X-Forwarded-For while
|
||||
// supporting both bare-metal (Caddy on localhost) and Docker deployments.
|
||||
app.set('trust proxy', ['loopback', '172.16.0.0/12', '10.0.0.0/8']);
|
||||
|
||||
// ── JSON body parser (default 1MB limit) ──
|
||||
app.use(express.json({ limit: LIMITS.BODY_DEFAULT }));
|
||||
@@ -124,16 +127,14 @@ module.exports = function configureMiddleware(app, {
|
||||
}
|
||||
|
||||
function extractTailscaleIPs(req) {
|
||||
// req.ip is already correctly resolved by Express's trust-proxy setting.
|
||||
// Only fall back to raw headers if req.ip is unavailable (e.g., before
|
||||
// trust proxy is fully configured in edge-case setups).
|
||||
const clientIP = req.ip || req.socket?.remoteAddress || '';
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
const realIP = req.headers['x-real-ip'];
|
||||
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
|
||||
const fromTailscale = ipsToCheck.some(ip =>
|
||||
isTailscaleIP(ip.toString().split(',')[0].trim()));
|
||||
const clientTailscaleIP = ipsToCheck
|
||||
.map(ip => ip.toString().split(',')[0].trim())
|
||||
.find(ip => isTailscaleIP(ip));
|
||||
return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP };
|
||||
const clientTailscaleIP = isTailscaleIP(clientIP) ? clientIP : null;
|
||||
const fromTailscale = clientTailscaleIP !== null;
|
||||
|
||||
return { clientIP, ipsToCheck: [clientIP], fromTailscale, clientTailscaleIP };
|
||||
}
|
||||
|
||||
async function isIPInTailnet(clientTailscaleIP) {
|
||||
@@ -460,6 +461,10 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
] : []),
|
||||
// DC-097b: Root-level Prometheus metrics endpoint — same output as
|
||||
// /api/v1/metrics/prometheus but at the conventional /metrics path that
|
||||
// Prometheus configs and documentation expect.
|
||||
{ path: '/metrics', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/version', exact: true, method: 'GET' },
|
||||
// Security ingest endpoints — auth via per-host Bearer token (handled in routes/security.js),
|
||||
// NOT via TOTP/JWT. This lets remote DashCaddy agents and log parsers push events
|
||||
@@ -504,6 +509,45 @@ module.exports = function configureMiddleware(app, {
|
||||
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
|
||||
};
|
||||
|
||||
|
||||
// ── Sensitive routes: block external access when TOTP is off ──
|
||||
// When TOTP is not enabled, all routes are open by design. But certain routes
|
||||
// expose infrastructure details (config, tailscale, license keys, service
|
||||
// manifests with container IDs, deployment manifests, port mappings) that
|
||||
// should not be accessible from the public internet. Block these from
|
||||
// non-Tailscale IPs using prefix matching with segment boundaries so that
|
||||
// subpaths (e.g. /api/v1/services/dc9201) are also protected.
|
||||
//
|
||||
// Each entry is matched as: path === prefix || path.startsWith(prefix + '/')
|
||||
const SENSITIVE_ROUTE_PREFIXES = [
|
||||
'/api/v1/config',
|
||||
'/api/v1/services',
|
||||
'/api/v1/tailscale',
|
||||
'/api/v1/updates',
|
||||
'/api/v1/license',
|
||||
'/api/v1/credentials',
|
||||
'/api/v1/health-checks',
|
||||
'/api/v1/disaster',
|
||||
'/api/v1/fleet',
|
||||
'/api/v1/disk',
|
||||
];
|
||||
|
||||
const sensitiveRouteMiddleware = (req, res, next) => {
|
||||
if (!totpConfig.enabled) {
|
||||
const isSensitive = SENSITIVE_ROUTE_PREFIXES.some(
|
||||
prefix => req.path === prefix || req.path.startsWith(prefix + '/')
|
||||
);
|
||||
if (isSensitive && !isTailscaleIP(getClientIP(req))) {
|
||||
return errorResponse(res, 403, '[DC-122] Access denied. This endpoint requires TOTP authentication or Tailscale access.', {
|
||||
requiresTotp: true
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
app.use(sensitiveRouteMiddleware);
|
||||
|
||||
app.use(totpAuthMiddleware);
|
||||
|
||||
// ── JWT/API Key authentication middleware ──
|
||||
@@ -562,7 +606,7 @@ module.exports = function configureMiddleware(app, {
|
||||
...RATE_LIMITS.GENERAL,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path === '/metrics' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
|
||||
message: { success: false, error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -77,6 +77,7 @@ const bundles = {
|
||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||
// defined in globals.js (already in core.js).
|
||||
JS('share-modal.js'),
|
||||
JS('i18n.js'),
|
||||
],
|
||||
'onboarding.js': [
|
||||
JS('driver.min.js'),
|
||||
|
||||
Vendored
+77
-77
File diff suppressed because one or more lines are too long
Vendored
+152
-152
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 638 B |
+18
-1
@@ -349,6 +349,9 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
// Skip if auth has been lost (e.g. TOTP gate activated externally).
|
||||
// The polling interval in init.js also checks this flag.
|
||||
if (window._dcAuthLost) return;
|
||||
if (refreshInFlight) {
|
||||
refreshQueued = true;
|
||||
return refreshInFlight;
|
||||
@@ -401,9 +404,21 @@
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/services/status', { cache: 'no-store' });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Auth lost — stop the polling loop and close SSE; do NOT fall
|
||||
// through to direct probes (those would misleadingly mark
|
||||
// services as healthy since /probe/ treats 401/403 as "up").
|
||||
window._dcAuthLost = true;
|
||||
if (window._sseReconnect && window._sseClose) {
|
||||
window._sseClose(); // tell SSE to stop reconnecting
|
||||
}
|
||||
updateStamp('auth required');
|
||||
return; // skip the fallback entirely
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status refresh failed (${response.status})`);
|
||||
}
|
||||
window._dcAuthLost = false; // auth working again
|
||||
const data = await response.json();
|
||||
applyBatchResults(data.statuses || {});
|
||||
updateStamp('last check', data.checkedAt || new Date());
|
||||
@@ -418,9 +433,11 @@
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
if (refreshQueued) {
|
||||
if (refreshQueued && !window._dcAuthLost) {
|
||||
refreshQueued = false;
|
||||
setTimeout(() => { window.refreshAll(); }, 0);
|
||||
} else {
|
||||
refreshQueued = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -63,7 +63,12 @@
|
||||
window.buildGrid();
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||
setInterval(() => {
|
||||
// Stop polling if the session has been invalidated (e.g. TOTP gate
|
||||
// now active, or user logged out). Avoids relentless 401/403 noise.
|
||||
if (window._dcAuthLost) return;
|
||||
window.refreshAll();
|
||||
}, DC.POLL.DASHBOARD);
|
||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
|
||||
document.getElementById('edit-service-name').value = service.name;
|
||||
document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id);
|
||||
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
|
||||
const logoPreview = document.getElementById('edit-service-logo-preview');
|
||||
const logoSrc = service.logo || `/assets/${service.id}.png`;
|
||||
logoPreview.src = logoSrc;
|
||||
// Hide broken images gracefully instead of showing a broken icon
|
||||
logoPreview.onerror = function() { this.style.display = 'none'; };
|
||||
logoPreview.onload = function() { this.style.display = ''; };
|
||||
logoPreview.style.display = '';
|
||||
document.getElementById('edit-subdomain').value = service.id;
|
||||
document.getElementById('edit-port').value = service.port || '';
|
||||
document.getElementById('edit-ip').value = service.ip || 'localhost';
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
let es = null;
|
||||
let reconnectDelay = 1000;
|
||||
const MAX_RECONNECT = 30000;
|
||||
let _sseFailCount = 0;
|
||||
let _sseManuallyClosed = false;
|
||||
|
||||
function connect() {
|
||||
if (es) { try { es.close(); } catch (_) {} }
|
||||
if (_sseManuallyClosed) return; // auth-lost: don't reconnect
|
||||
|
||||
es = new EventSource('/api/v1/events/stream');
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
reconnectDelay = 1000; // reset backoff
|
||||
_sseFailCount = 0; // reset failure counter
|
||||
debug('[SSE] Connected to event stream');
|
||||
});
|
||||
|
||||
@@ -101,15 +105,34 @@
|
||||
// Reconnect on error
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
// If auth was explicitly lost (401/403 from the polling loop),
|
||||
// don't attempt reconnection at all.
|
||||
if (window._dcAuthLost || _sseManuallyClosed) {
|
||||
console.warn('[SSE] Auth lost — stopping reconnection');
|
||||
return;
|
||||
}
|
||||
// Transient failures: retry with exponential backoff, stop after 5
|
||||
_sseFailCount++;
|
||||
if (_sseFailCount > 5) {
|
||||
console.warn('[SSE] Max reconnect attempts reached — stopping (server unreachable)');
|
||||
return;
|
||||
}
|
||||
console.warn(`[SSE] Disconnected, reconnecting in ${reconnectDelay / 1000}s...`);
|
||||
setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT);
|
||||
};
|
||||
}
|
||||
|
||||
// Called by grid.js when the polling loop detects auth loss (401/403)
|
||||
function closeAndStop() {
|
||||
_sseManuallyClosed = true;
|
||||
if (es) { try { es.close(); } catch (_) {} }
|
||||
}
|
||||
|
||||
// Start on page load
|
||||
connect();
|
||||
|
||||
// Expose for debugging
|
||||
// Expose for debugging and cross-module coordination
|
||||
window._sseReconnect = connect;
|
||||
window._sseClose = closeAndStop;
|
||||
})();
|
||||
|
||||
@@ -252,10 +252,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
skipBtn.onclick = async function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Skip setup? You can run it later from Settings.')) {
|
||||
// Save skip status to server
|
||||
await saveConfigToServer({ setupComplete: true, skipped: true, timestamp: new Date().toISOString() });
|
||||
// Save skip status — hide wizard FIRST so it always dismisses,
|
||||
// then attempt server save (fire-and-forget, never blocks UI)
|
||||
safeSet('dashcaddy-setup', 'skipped');
|
||||
document.getElementById('setup-wizard').style.display = 'none';
|
||||
saveConfigToServer({ setupComplete: true, skipped: true, timestamp: new Date().toISOString() }).catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /mcp
|
||||
|
||||
Sitemap: https://test.dashcaddy.net/sitemap.xml
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
|
||||
const CACHE = 'dashcaddy-shell-c5ac9d9802';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user