Compare commits
22
Commits
b5e23d8e3f
...
7967279b5b
| 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', 'zh')).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('zh')).toBe(false);
|
||||
expect(i18n.isSupported('ja')).toBe(false);
|
||||
expect(i18n.isSupported('klingon')).toBe(false);
|
||||
expect(i18n.isSupported('xx')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,8 +81,8 @@ describe('DC-077: i18n system', () => {
|
||||
});
|
||||
|
||||
it('defaults to English for unsupported languages', () => {
|
||||
expect(i18n.detectLanguage('zh-CN,zh;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('ja-JP,ja;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('klingon-KR,klingon;q=0.9')).toBe('en');
|
||||
expect(i18n.detectLanguage('xx-XX,xx;q=0.9')).toBe('en');
|
||||
});
|
||||
|
||||
it('strips region codes before matching', () => {
|
||||
|
||||
@@ -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 5 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(5);
|
||||
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 };
|
||||
|
||||
@@ -1764,6 +1764,42 @@ const APP_TEMPLATES = {
|
||||
]
|
||||
},
|
||||
|
||||
"vintage-radio": {
|
||||
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",
|
||||
docker: {
|
||||
image: "nginx:alpine",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [
|
||||
"/opt/vintage-radio/web:/usr/share/nginx/html:ro"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "radio",
|
||||
defaultPort: 8090,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
staticSite: true,
|
||||
features: [
|
||||
"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: [
|
||||
"Open radio.sami (or your configured subdomain)",
|
||||
"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"]
|
||||
},
|
||||
|
||||
"airsonic": {
|
||||
name: "Airsonic Advanced",
|
||||
description: "Free web-based media streamer",
|
||||
@@ -2541,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,9 +529,22 @@ function isSupported(lang) { return SUPPORTED_LANGUAGES.indexOf(lang) >= 0; }
|
||||
function detectLanguage(acceptLanguage) {
|
||||
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
||||
var parts = acceptLanguage.split(',');
|
||||
var parsed = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var code = parts[i].trim().split(';')[0].split('-')[0].toLowerCase();
|
||||
if (isSupported(code)) return code;
|
||||
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 < langParts.length; j++) {
|
||||
var kv = langParts[j].trim().split('=');
|
||||
if (kv[0] === 'q' && kv[1]) q = parseFloat(kv[1]);
|
||||
}
|
||||
parsed.push({ code: code, q: isNaN(q) ? 1.0 : q });
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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' }
|
||||
});
|
||||
|
||||
|
||||
@@ -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
-76
File diff suppressed because one or more lines are too long
Vendored
+286
-247
File diff suppressed because one or more lines are too long
Vendored
+2
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 638 B |
+63
-1
@@ -654,6 +654,23 @@
|
||||
<!-- Will be filled dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Disk safety info: health checks accumulate data over time -->
|
||||
<div style="margin-top: 16px; padding: 14px 16px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 10%, transparent); border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||
<span style="font-size: 1.2rem; line-height: 1;">💾</span>
|
||||
<div>
|
||||
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage & Health-Check Data</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted); line-height: 1.45;">
|
||||
DashCaddy's health checks log response times, uptime history, and incidents for every monitored service.
|
||||
Over time this data accumulates and can consume significant disk space — especially on small VPS or
|
||||
SD-card installs. After setup, open <strong>Health → Configure → Global Settings</strong> to set a
|
||||
<strong>data retention period</strong> (default 30 days), adjust the <strong>polling interval</strong>,
|
||||
and configure a <strong>disk-usage warning threshold</strong> so you're alerted before storage runs low.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||
@@ -663,7 +680,49 @@
|
||||
|
||||
<div class="setup-wizard-buttons">
|
||||
<button id="setup-summary-back">← Back</button>
|
||||
<button id="setup-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||||
<button id="setup-summary-next" class="setup-btn-primary">Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk Safety Warning step (shown after the configuration summary) -->
|
||||
<div class="setup-step" id="setup-step-disk-safety" style="display: none;">
|
||||
<h2 style="margin: 0 0 8px;">⚠️ Disk Usage Note</h2>
|
||||
<p class="setup-desc">Important information about storage before you finish</p>
|
||||
|
||||
<div style="margin-top: 8px; padding: 18px 20px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 12%, transparent); border-radius: 10px; border: 1px solid var(--warn-fg, #f39c12);">
|
||||
<div style="display: flex; gap: 12px; align-items: flex-start;">
|
||||
<span style="font-size: 1.5rem; line-height: 1.2;">⚠️</span>
|
||||
<div style="font-size: 0.92rem; line-height: 1.55; color: var(--text);">
|
||||
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage Note:</strong>
|
||||
DashCaddy stores health check history, container statistics, and event logs.
|
||||
On a busy server, this data can accumulate over time.
|
||||
Set appropriate retention limits in <strong>Settings → Health</strong> to prevent disk fill.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; padding: 14px 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<strong style="font-size: 0.9rem;">📋 Recommended after setup</strong>
|
||||
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 0.85rem; color: var(--muted); line-height: 1.6;">
|
||||
<li>Open <strong>Health → Configure → Global Settings</strong></li>
|
||||
<li>Set a <strong>health check polling interval</strong> (default: 60s)</li>
|
||||
<li>Set a <strong>stats polling interval</strong> (default: 30s)</li>
|
||||
<li>Set a <strong>data retention period</strong> (default: 30 days)</li>
|
||||
<li>Cap <strong>max entries per service</strong> (default: 500)</li>
|
||||
<li>Set a <strong>disk-usage warning threshold</strong> (default: 80%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
|
||||
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
|
||||
Go to Settings → System Configuration to edit your setup anytime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-wizard-buttons">
|
||||
<button id="setup-disk-safety-back">← Back</button>
|
||||
<button id="setup-disk-safety-finish" class="setup-btn-primary" style="background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg);">✓ Finish Setup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -959,6 +1018,9 @@
|
||||
<script src="/js/language-selector.js" defer></script>
|
||||
|
||||
<!-- Bundled JS (built with: npm run build) -->
|
||||
<!-- i18n (language selector + translation system) -->
|
||||
<script src="/js/i18n.js" defer></script>
|
||||
|
||||
<script src="/dist/core.js" defer></script>
|
||||
<script src="/dist/features.js" defer></script>
|
||||
<script src="/dist/onboarding.js" defer></script>
|
||||
|
||||
+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';
|
||||
|
||||
+19
-1
@@ -1,10 +1,28 @@
|
||||
// ===== DASHBOARD CONSTANTS =====
|
||||
// Honor persisted health retention settings for polling cadences so the
|
||||
// Settings → Health → Global Settings panel actually takes effect. The
|
||||
// STATS interval (resource/container stat sampling) is driven from the
|
||||
// user-configurable statsPollingInterval. HEALTH is the lightweight card
|
||||
// badge refresh and stays at its fast default unless overridden.
|
||||
(function applyHealthPollingSettings() {
|
||||
try {
|
||||
var raw = (typeof localStorage !== 'undefined' && localStorage.getItem('dashcaddy-health-settings')) || null;
|
||||
if (raw) {
|
||||
var s = JSON.parse(raw);
|
||||
// values are stored in seconds; DC.POLL expects milliseconds
|
||||
if (s.statsPollingInterval && s.statsPollingInterval >= 5 && s.statsPollingInterval <= 3600) {
|
||||
window.__DC_STATS_OVERRIDE = s.statsPollingInterval * 1000;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* ignore — fall back to defaults below */ }
|
||||
})();
|
||||
|
||||
const DC = {
|
||||
NAME: 'DashCaddy',
|
||||
POLL: {
|
||||
DASHBOARD: 10000, // 10s — main refreshAll interval
|
||||
LOGS: 3000, // 3s — log viewer updates
|
||||
STATS: 5000, // 5s — resource monitor refresh
|
||||
STATS: (typeof window !== 'undefined' && window.__DC_STATS_OVERRIDE) || 5000, // 5s default — resource monitor refresh (overridable via Settings → Health)
|
||||
WEATHER: 600000, // 10m — weather widget refresh
|
||||
HEALTH: 1000, // 1s — card health badge refresh
|
||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
||||
|
||||
@@ -34,6 +34,44 @@
|
||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Settings: retention, polling intervals, max entries, disk-usage threshold -->
|
||||
<div id="health-global-settings" style="margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 style="margin: 0 0 4px;">🌍 Global Settings</h4>
|
||||
<p class="text-muted-sm" style="margin: 0 0 12px;">Applies to all health checks. Settings are stored locally in this browser.</p>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Health Check Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-interval" value="60" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service's health endpoint is checked.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Stats Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-stats-interval" value="30" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often container statistics (CPU/memory) are sampled.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Max Entries Per Service</label>
|
||||
<input type="number" id="health-setting-max-entries" value="500" min="10" max="100000" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Cap on stored history records per service.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Data Retention (days)</label>
|
||||
<input type="number" id="health-setting-retention" value="30" min="1" max="3650" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Health history older than this is pruned.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Disk-Usage Warning (%)</label>
|
||||
<input type="number" id="health-setting-disk-threshold" value="80" min="50" max="99" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Warn when disk usage exceeds this level.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<button id="health-global-save" class="btn-accent-solid">Save Global Settings</button>
|
||||
<button id="health-global-reset" class="btn-sm">Reset to Defaults</button>
|
||||
<span id="health-global-status" style="font-size: 0.8rem; color: var(--muted);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Health Check Form -->
|
||||
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
||||
@@ -103,6 +141,77 @@
|
||||
const formCancel = document.getElementById('health-form-cancel');
|
||||
const formSave = document.getElementById('health-form-save');
|
||||
|
||||
// ---- Global health settings (retention, polling intervals, max entries, disk threshold) ----
|
||||
const HEALTH_SETTINGS_KEY = 'dashcaddy-health-settings';
|
||||
const HEALTH_DEFAULTS = { retentionDays: 30, pollingInterval: 60, statsPollingInterval: 30, maxEntriesPerService: 500, diskUsageThreshold: 80 };
|
||||
const globalSaveBtn = document.getElementById('health-global-save');
|
||||
const globalResetBtn = document.getElementById('health-global-reset');
|
||||
const globalStatusSpan = document.getElementById('health-global-status');
|
||||
const retentionInput = document.getElementById('health-setting-retention');
|
||||
const intervalInput = document.getElementById('health-setting-interval');
|
||||
const statsIntervalInput = document.getElementById('health-setting-stats-interval');
|
||||
const maxEntriesInput = document.getElementById('health-setting-max-entries');
|
||||
const diskThresholdInput = document.getElementById('health-setting-disk-threshold');
|
||||
|
||||
function loadHealthSettings() {
|
||||
try {
|
||||
const raw = safeGet(HEALTH_SETTINGS_KEY);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
return Object.assign({}, HEALTH_DEFAULTS, saved);
|
||||
} catch (_) {
|
||||
return Object.assign({}, HEALTH_DEFAULTS);
|
||||
}
|
||||
}
|
||||
|
||||
function applyHealthSettingsToUI() {
|
||||
const s = loadHealthSettings();
|
||||
if (retentionInput) retentionInput.value = s.retentionDays;
|
||||
if (intervalInput) intervalInput.value = s.pollingInterval;
|
||||
if (statsIntervalInput) statsIntervalInput.value = s.statsPollingInterval;
|
||||
if (maxEntriesInput) maxEntriesInput.value = s.maxEntriesPerService;
|
||||
if (diskThresholdInput) diskThresholdInput.value = s.diskUsageThreshold;
|
||||
}
|
||||
|
||||
function saveHealthSettings() {
|
||||
const settings = {
|
||||
retentionDays: Math.max(1, Math.min(3650, parseInt(retentionInput?.value) || HEALTH_DEFAULTS.retentionDays)),
|
||||
pollingInterval: Math.max(5, Math.min(3600, parseInt(intervalInput?.value) || HEALTH_DEFAULTS.pollingInterval)),
|
||||
statsPollingInterval: Math.max(5, Math.min(3600, parseInt(statsIntervalInput?.value) || HEALTH_DEFAULTS.statsPollingInterval)),
|
||||
maxEntriesPerService: Math.max(10, Math.min(100000, parseInt(maxEntriesInput?.value) || HEALTH_DEFAULTS.maxEntriesPerService)),
|
||||
diskUsageThreshold: Math.max(50, Math.min(99, parseInt(diskThresholdInput?.value) || HEALTH_DEFAULTS.diskUsageThreshold))
|
||||
};
|
||||
try {
|
||||
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(settings));
|
||||
applyHealthSettingsToUI();
|
||||
if (globalStatusSpan) {
|
||||
globalStatusSpan.textContent = 'Saved ✓';
|
||||
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||
}
|
||||
if (typeof showNotification === 'function') showNotification('Global health settings saved', 'success');
|
||||
} catch (e) {
|
||||
if (globalStatusSpan) { globalStatusSpan.textContent = 'Save failed'; globalStatusSpan.style.color = 'var(--bad-fg)'; }
|
||||
if (typeof showNotification === 'function') showNotification('Failed to save settings: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetHealthSettings() {
|
||||
try {
|
||||
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(HEALTH_DEFAULTS));
|
||||
} catch (_) { /* ignore */ }
|
||||
applyHealthSettingsToUI();
|
||||
if (globalStatusSpan) {
|
||||
globalStatusSpan.textContent = 'Reset to defaults ✓';
|
||||
globalStatusSpan.style.color = 'var(--ok-fg)';
|
||||
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
applyHealthSettingsToUI();
|
||||
globalSaveBtn?.addEventListener('click', saveHealthSettings);
|
||||
globalResetBtn?.addEventListener('click', resetHealthSettings);
|
||||
// ---- End global health settings ----
|
||||
|
||||
let editingId = null;
|
||||
|
||||
function uptimeColor(pct) {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* DC-077: i18n Frontend — Language selector and translation system
|
||||
*
|
||||
* Provides window.DCI18n.t(key) for the dashboard frontend.
|
||||
* Loads translations from /api/v1/i18n/translations/:lang
|
||||
* Language preference stored in localStorage.
|
||||
* Handles RTL for Arabic.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'dashcaddy-language';
|
||||
const DEFAULT_LANG = 'en';
|
||||
// Must match the 31 languages in language-selector.js and the backend i18n route.
|
||||
const SUPPORTED_LANGS = [
|
||||
'en', 'ar', 'bn', 'cs', 'da', 'de', 'el', 'es', 'fa', 'fi',
|
||||
'fr', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no',
|
||||
'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh',
|
||||
];
|
||||
const LANG_NAMES = {
|
||||
en: 'English', ar: 'العربية', bn: 'বাংলা', cs: 'Čeština', da: 'Dansk',
|
||||
de: 'Deutsch', el: 'Ελληνικά', es: 'Español', fa: 'فارسی', fi: 'Suomi',
|
||||
fr: 'Français', hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano',
|
||||
ja: '日本語', ko: '한국어', ms: 'Bahasa Melayu', nl: 'Nederlands', no: 'Norsk',
|
||||
pl: 'Polski', pt: 'Português', ro: 'Română', ru: 'Русский', sv: 'Svenska',
|
||||
th: 'ไทย', tr: 'Türkçe', uk: 'Українська', ur: 'اردو', vi: 'Tiếng Việt', zh: '中文',
|
||||
};
|
||||
// RTL languages need dir="rtl" on the document element. (No Hebrew per project policy.)
|
||||
const RTL_LANGS = new Set(['ar', 'fa', 'ur']);
|
||||
|
||||
// Validate the stored language — if it's invalid (old/corrupt), fall back to default.
|
||||
// Wrap in try/catch for environments where localStorage is disabled (private mode).
|
||||
function _readValidLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
let currentLang = _readValidLang();
|
||||
let translations = {};
|
||||
let loaded = false;
|
||||
// Monotonic token to guard against out-of-order async resolution.
|
||||
// Each setLanguage / loadTranslations call captures the current value; if it
|
||||
// changed by the time the fetch resolves, the result is discarded.
|
||||
let _langRequestId = 0;
|
||||
|
||||
async function loadTranslations(lang, reqId) {
|
||||
// reqId is the monotonic token incremented by the caller (setLanguage/init).
|
||||
// If not provided (direct API call), allocate one for backward compatibility.
|
||||
if (reqId === undefined) reqId = ++_langRequestId;
|
||||
if (lang === DEFAULT_LANG) {
|
||||
translations = {}; // English is the default — no translation needed
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
|
||||
// Guard against out-of-order resolution: if another loadTranslations
|
||||
// started after this one (or the user switched languages), discard.
|
||||
if (reqId !== _langRequestId) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (reqId !== _langRequestId) return; // double-check after second await
|
||||
translations = data.translations || {};
|
||||
loaded = true;
|
||||
} else {
|
||||
// HTTP error — clear stale translations so we don't show the wrong language
|
||||
translations = {};
|
||||
loaded = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Failed to load translations for', lang, e);
|
||||
if (reqId === _langRequestId) {
|
||||
translations = {};
|
||||
loaded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (currentLang === DEFAULT_LANG) return key;
|
||||
// If translations didn't load, fall back to the English key
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||
currentLang = lang;
|
||||
try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
|
||||
|
||||
// RTL handling — always set dir/lang explicitly so switching back to LTR works.
|
||||
const isRtl = RTL_LANGS.has(lang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
|
||||
loadTranslations(lang, reqId).then(() => {
|
||||
// Only apply if this is still the latest request.
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
function applyTranslations() {
|
||||
// Apply translations to elements with data-i18n attributes.
|
||||
// Always write the resolved value — when switching back to English or when a
|
||||
// key has no translation, this restores the original English text rather than
|
||||
// leaving the previous language's translated text visible.
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
// Apply to titles
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
}
|
||||
|
||||
function createLanguageSelector() {
|
||||
// Find the top bar area to insert the selector
|
||||
// Look for the auth-settings area or the header actions
|
||||
const targetContainer = document.querySelector('.header-actions') ||
|
||||
document.querySelector('#auth-settings-btn')?.parentElement ||
|
||||
document.querySelector('.top-bar-actions');
|
||||
|
||||
if (!targetContainer) {
|
||||
// If we can't find a target, try to add it near the settings button
|
||||
const settingsBtn = document.getElementById('auth-settings-btn');
|
||||
if (settingsBtn && settingsBtn.parentElement) {
|
||||
return createDropdown(settingsBtn.parentElement);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return createDropdown(targetContainer);
|
||||
}
|
||||
|
||||
function createDropdown(container) {
|
||||
// Check if selector already exists
|
||||
if (document.getElementById('dc-lang-selector')) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'dc-lang-selector';
|
||||
wrapper.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'dc-lang-btn';
|
||||
btn.className = 'lang-selector-btn';
|
||||
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
|
||||
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
|
||||
btn.title = 'Select Language';
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.id = 'dc-lang-dropdown';
|
||||
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 160px; max-height: 320px; overflow-y: auto;';
|
||||
|
||||
SUPPORTED_LANGS.forEach(lang => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'lang-option';
|
||||
item.style.cssText = 'padding: 8px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-primary, #e0e0e0);';
|
||||
item.onmouseenter = () => item.style.background = 'var(--card-hover, rgba(255,255,255,0.05))';
|
||||
item.onmouseleave = () => item.style.background = 'transparent';
|
||||
|
||||
const flag = document.createElement('span');
|
||||
flag.textContent = lang === currentLang ? '✓' : '';
|
||||
flag.style.cssText = 'width: 16px; color: var(--ok-fg, #4ade80);';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = LANG_NAMES[lang];
|
||||
|
||||
item.appendChild(flag);
|
||||
item.appendChild(name);
|
||||
item.onclick = () => {
|
||||
setLanguage(lang);
|
||||
dropdown.style.display = 'none';
|
||||
// Update button text
|
||||
btn.querySelector('.lang-current').textContent = lang.toUpperCase();
|
||||
// Update checkmarks
|
||||
dropdown.querySelectorAll('.lang-option').forEach((opt, i) => {
|
||||
opt.querySelector('span').textContent = SUPPORTED_LANGS[i] === lang ? '✓' : '';
|
||||
});
|
||||
// Show notification
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Language: ${LANG_NAMES[lang]}`, 'info');
|
||||
}
|
||||
};
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!wrapper.contains(e.target)) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
wrapper.appendChild(dropdown);
|
||||
container.insertBefore(wrapper, container.firstChild);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
function init() {
|
||||
// Always set dir/lang explicitly — covers LTR reset and RTL setup.
|
||||
const isRtl = RTL_LANGS.has(currentLang);
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
function start() {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) {
|
||||
const reqId = ++_langRequestId;
|
||||
loadTranslations(currentLang, reqId).then(() => {
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||
|
||||
// Auto-init
|
||||
init();
|
||||
})();
|
||||
+180
-15
@@ -2,12 +2,13 @@
|
||||
* DC-077: i18n Language Selector
|
||||
*
|
||||
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
||||
* the dashboard language between en / es / zh / ar / de.
|
||||
* the dashboard language. Supports all 31 backend languages with a searchable list.
|
||||
*
|
||||
* - Shows current language with flag emoji
|
||||
* - Persists selection to localStorage('dashcaddy-language')
|
||||
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
|
||||
* - Reloads the page on change so the new language takes effect
|
||||
* - Search filter for quickly finding a language in the 31-option list
|
||||
*
|
||||
* Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
|
||||
*/
|
||||
@@ -18,11 +19,37 @@
|
||||
const CONFIG_ENDPOINT = '/api/v1/config';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', flag: '🇺🇸', label: 'English' },
|
||||
{ code: 'es', flag: '🇪🇸', label: 'Español' },
|
||||
{ code: 'zh', flag: '🇨🇳', label: '中文' },
|
||||
{ code: 'ar', flag: '🇸🇦', label: 'العربية' },
|
||||
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' },
|
||||
{ code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' },
|
||||
{ code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' },
|
||||
{ code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' },
|
||||
{ code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' },
|
||||
{ code: 'da', flag: '🇩🇰', label: 'Danish', nativeLabel: 'Dansk' },
|
||||
{ code: 'de', flag: '🇩🇪', label: 'German', nativeLabel: 'Deutsch' },
|
||||
{ code: 'el', flag: '🇬🇷', label: 'Greek', nativeLabel: 'Ελληνικά' },
|
||||
{ code: 'es', flag: '🇪🇸', label: 'Spanish', nativeLabel: 'Español' },
|
||||
{ code: 'fa', flag: '🇮🇷', label: 'Persian', nativeLabel: 'فارسی' },
|
||||
{ code: 'fi', flag: '🇫🇮', label: 'Finnish', nativeLabel: 'Suomi' },
|
||||
{ code: 'fr', flag: '🇫🇷', label: 'French', nativeLabel: 'Français' },
|
||||
{ code: 'hi', flag: '🇮🇳', label: 'Hindi', nativeLabel: 'हिन्दी' },
|
||||
{ code: 'hu', flag: '🇭🇺', label: 'Hungarian', nativeLabel: 'Magyar' },
|
||||
{ code: 'id', flag: '🇮🇩', label: 'Indonesian', nativeLabel: 'Bahasa Indonesia' },
|
||||
{ code: 'it', flag: '🇮🇹', label: 'Italian', nativeLabel: 'Italiano' },
|
||||
{ code: 'ja', flag: '🇯🇵', label: 'Japanese', nativeLabel: '日本語' },
|
||||
{ code: 'ko', flag: '🇰🇷', label: 'Korean', nativeLabel: '한국어' },
|
||||
{ code: 'ms', flag: '🇲🇾', label: 'Malay', nativeLabel: 'Bahasa Melayu' },
|
||||
{ code: 'nl', flag: '🇳🇱', label: 'Dutch', nativeLabel: 'Nederlands' },
|
||||
{ code: 'no', flag: '🇳🇴', label: 'Norwegian', nativeLabel: 'Norsk' },
|
||||
{ code: 'pl', flag: '🇵🇱', label: 'Polish', nativeLabel: 'Polski' },
|
||||
{ code: 'pt', flag: '🇵🇹', label: 'Portuguese', nativeLabel: 'Português' },
|
||||
{ code: 'ro', flag: '🇷🇴', label: 'Romanian', nativeLabel: 'Română' },
|
||||
{ code: 'ru', flag: '🇷🇺', label: 'Russian', nativeLabel: 'Русский' },
|
||||
{ code: 'sv', flag: '🇸🇪', label: 'Swedish', nativeLabel: 'Svenska' },
|
||||
{ code: 'th', flag: '🇹🇭', label: 'Thai', nativeLabel: 'ไทย' },
|
||||
{ code: 'tr', flag: '🇹🇷', label: 'Turkish', nativeLabel: 'Türkçe' },
|
||||
{ code: 'uk', flag: '🇺🇦', label: 'Ukrainian', nativeLabel: 'Українська' },
|
||||
{ code: 'ur', flag: '🇵🇰', label: 'Urdu', nativeLabel: 'اردو' },
|
||||
{ code: 'vi', flag: '🇻🇳', label: 'Vietnamese', nativeLabel: 'Tiếng Việt' },
|
||||
{ code: 'zh', flag: '🇨🇳', label: 'Chinese', nativeLabel: '中文' },
|
||||
];
|
||||
|
||||
const SUPPORTED = LANGUAGES.map(l => l.code);
|
||||
@@ -82,7 +109,10 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
min-width: 200px;
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-base, #1e1e2e);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
@@ -92,7 +122,35 @@
|
||||
display: none;
|
||||
}
|
||||
.dc-lang-menu.open {
|
||||
display: block;
|
||||
display: flex;
|
||||
}
|
||||
.dc-lang-search {
|
||||
margin: 2px 0 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-base, #111);
|
||||
color: var(--fg);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dc-lang-search:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.dc-lang-list {
|
||||
overflow-y: auto;
|
||||
max-height: 280px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dc-lang-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
.dc-lang-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dc-lang-option {
|
||||
display: flex;
|
||||
@@ -126,6 +184,11 @@
|
||||
.dc-lang-option.active .dc-lang-check {
|
||||
opacity: 1;
|
||||
}
|
||||
.dc-lang-option.dc-lang-focus,
|
||||
.dc-lang-option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.dc-lang-label-sm {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
@@ -146,19 +209,111 @@
|
||||
menu.className = 'dc-lang-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
// Search input
|
||||
const search = document.createElement('input');
|
||||
search.type = 'text';
|
||||
search.className = 'dc-lang-search';
|
||||
search.placeholder = 'Search language…';
|
||||
search.setAttribute('aria-label', 'Search languages');
|
||||
search.autocomplete = 'off';
|
||||
|
||||
// Scrollable option list
|
||||
const list = document.createElement('div');
|
||||
list.className = 'dc-lang-list';
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
const opt = document.createElement('div');
|
||||
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
|
||||
opt.setAttribute('role', 'menuitemradio');
|
||||
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
|
||||
opt.setAttribute('tabindex', '-1');
|
||||
opt.dataset.lang = lang.code;
|
||||
opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase();
|
||||
opt.innerHTML =
|
||||
'<span class="dc-lang-flag">' + lang.flag + '</span>' +
|
||||
'<span class="dc-lang-name">' + lang.label + '</span>' +
|
||||
'<span class="dc-lang-name">' + lang.nativeLabel +
|
||||
'<span style="opacity:0.5;font-size:0.8em;margin-left:6px;">' + lang.label + '</span>' +
|
||||
'</span>' +
|
||||
'<span class="dc-lang-check">✓</span>';
|
||||
menu.appendChild(opt);
|
||||
list.appendChild(opt);
|
||||
}
|
||||
return menu;
|
||||
|
||||
// Filter logic — extracted so we can reset from the open handler
|
||||
function applyFilter(query) {
|
||||
var q = (query || '').toLowerCase().trim();
|
||||
list.querySelectorAll('.dc-lang-option').forEach(function (opt) {
|
||||
var match = !q || opt.dataset.search.indexOf(q) !== -1;
|
||||
opt.style.display = match ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
search.addEventListener('input', function () {
|
||||
applyFilter(this.value);
|
||||
});
|
||||
|
||||
// Prevent clicks on search from closing the menu
|
||||
search.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
|
||||
// Expose reset so init() can restore visibility when reopening
|
||||
menu._resetFilter = function () {
|
||||
search.value = '';
|
||||
applyFilter('');
|
||||
};
|
||||
|
||||
// ===== Keyboard navigation (Arrow Up/Down, Enter, Space) =====
|
||||
function getVisibleOptions() {
|
||||
return Array.from(list.querySelectorAll('.dc-lang-option')).filter(
|
||||
function (o) { return o.style.display !== 'none'; }
|
||||
);
|
||||
}
|
||||
|
||||
function focusOption(opt) {
|
||||
if (!opt) return;
|
||||
var visible = getVisibleOptions();
|
||||
visible.forEach(function (o) { o.classList.remove('dc-lang-focus'); });
|
||||
opt.classList.add('dc-lang-focus');
|
||||
opt.focus();
|
||||
}
|
||||
|
||||
search.addEventListener('keydown', function (e) {
|
||||
var visible = getVisibleOptions();
|
||||
if (visible.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
focusOption(visible[0]);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var active = list.querySelector('.dc-lang-option.active');
|
||||
if (active && active.style.display !== 'none') selectLanguage(active.dataset.lang);
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('keydown', function (e) {
|
||||
var visible = getVisibleOptions();
|
||||
var currentIdx = visible.indexOf(document.activeElement);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var next = visible[Math.min(currentIdx + 1, visible.length - 1)];
|
||||
focusOption(next);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (currentIdx === 0) {
|
||||
search.focus();
|
||||
} else {
|
||||
focusOption(visible[currentIdx - 1]);
|
||||
}
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
var opt = document.activeElement;
|
||||
if (opt && opt.classList.contains('dc-lang-option')) {
|
||||
selectLanguage(opt.dataset.lang);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menu.appendChild(search);
|
||||
menu.appendChild(list);
|
||||
return { menu: menu, search: search };
|
||||
}
|
||||
|
||||
async function selectLanguage(code) {
|
||||
@@ -207,7 +362,9 @@
|
||||
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
||||
'<span class="dc-lang-caret">▼</span>';
|
||||
|
||||
const menu = buildMenu(current);
|
||||
const built = buildMenu(current);
|
||||
const menu = built.menu;
|
||||
const searchInput = built.search;
|
||||
|
||||
// Small label beneath, matching the "Customize Theme" link style
|
||||
const label = document.createElement('span');
|
||||
@@ -223,9 +380,16 @@
|
||||
e.stopPropagation();
|
||||
const isOpen = menu.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (isOpen) {
|
||||
// Reset filter: clear search text AND restore all hidden options
|
||||
if (typeof menu._resetFilter === 'function') {
|
||||
menu._resetFilter();
|
||||
}
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Option clicks
|
||||
// Option clicks (delegate to the list container)
|
||||
menu.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('.dc-lang-option');
|
||||
if (!opt) return;
|
||||
@@ -243,11 +407,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape
|
||||
// Close on Escape — return focus to the trigger button for accessibility
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
||||
menu.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.focus();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -377,8 +378,26 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
};
|
||||
}
|
||||
|
||||
// Finish setup button
|
||||
const finishBtn = document.getElementById('setup-finish');
|
||||
// Summary "Continue →" — advance to the disk-safety warning step
|
||||
const summaryNext = document.getElementById('setup-summary-next');
|
||||
if (summaryNext) {
|
||||
summaryNext.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-disk-safety');
|
||||
};
|
||||
}
|
||||
|
||||
// Disk-safety step navigation
|
||||
const diskSafetyBack = document.getElementById('setup-disk-safety-back');
|
||||
if (diskSafetyBack) {
|
||||
diskSafetyBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-summary');
|
||||
};
|
||||
}
|
||||
|
||||
// Finish setup button (now on the disk-safety step)
|
||||
const finishBtn = document.getElementById('setup-disk-safety-finish');
|
||||
if (finishBtn) {
|
||||
finishBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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-c4c69c2c4c';
|
||||
const CACHE = 'dashcaddy-shell-c5ac9d9802';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user