Compare commits
15
Commits
7967279b5b
...
dc/DC-051
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bb3f6db8 | ||
|
|
5f95fdcf70 | ||
|
|
45cfa83bad | ||
|
|
6d875e4631 | ||
|
|
4555d829ac | ||
|
|
e99413150e | ||
|
|
295c63ce94 | ||
|
|
ef685e515e | ||
|
|
bd40fb1c17 | ||
|
|
86cc21c7a4 | ||
|
|
ff92706f8a | ||
|
|
e8ab0e09a0 | ||
|
|
b5e23d8e3f | ||
|
|
87054e55d9 | ||
|
|
ec96060b2e |
No files matched your search
@@ -3,3 +3,4 @@ coverage/
|
||||
dist/
|
||||
build/
|
||||
*.min.js
|
||||
static-sites/
|
||||
@@ -1,10 +1,10 @@
|
||||
# ── Build stage: install all deps (including devDeps for build tooling) ──────
|
||||
# ── Dependency stage: deterministic production-only install ────────────────
|
||||
FROM node:20.11.1-alpine3.19 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ── Production stage: only production deps + source ──────────────────────────
|
||||
FROM node:20.11.1-alpine3.19
|
||||
@@ -22,6 +22,7 @@ COPY *.js ./
|
||||
COPY src/ ./src/
|
||||
COPY routes/ ./routes/
|
||||
COPY openapi.yaml ./
|
||||
COPY package.json ./
|
||||
|
||||
# VERSION file holds the short git SHA the image was built from.
|
||||
COPY VERSION ./
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Invoice rendering tests — DC-058.
|
||||
*
|
||||
* Pure functions. No live network, no SMTP, no Stripe SDK. Covers:
|
||||
* - HTML escaping for every user-controlled field
|
||||
* - CRLF/control-char neutralization (SMTP header injection defense)
|
||||
* - Plain-text fallback has the same content
|
||||
* - PDF is a valid PDF (magic bytes + loadable by pdf-parse)
|
||||
* - Invoice number derived from event id (deterministic)
|
||||
* - Catalog integration: missing productId still produces valid output
|
||||
*
|
||||
* Pairs with stripe-license-bridge.test.js (which covers the SMTP wiring
|
||||
* on top of these primitives).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const invoice = require('../../src/billing/invoice');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
// pdf-parse is the canonical tool to extract text from a PDF buffer for
|
||||
// verification. We keep it as a soft dependency — if it's not available,
|
||||
// the text-content tests skip rather than fail.
|
||||
let pdfParse = null;
|
||||
try {
|
||||
pdfParse = require('pdf-parse');
|
||||
} catch (_) {
|
||||
pdfParse = null;
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
email: 'alice@example.com',
|
||||
customerName: 'Alice Johnson',
|
||||
code: 'DC-PRO-30D-AB12CD34',
|
||||
durationDays: 30,
|
||||
productLabel: '1 month',
|
||||
productId: 'pro-30d',
|
||||
amountCents: 2000,
|
||||
currency: 'USD',
|
||||
eventId: 'evt_4f2c9b3a8b1d',
|
||||
sessionId: 'cs_test_a1b2c3d4e5',
|
||||
supportUrl: 'https://dashcaddy.net',
|
||||
};
|
||||
|
||||
describe('billing/invoice', () => {
|
||||
describe('generateInvoiceNumber', () => {
|
||||
test('strips evt_ prefix and produces INV-{8 hex chars}', () => {
|
||||
expect(invoice.generateInvoiceNumber('evt_4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
|
||||
});
|
||||
|
||||
test('uppercases mixed-case event ids', () => {
|
||||
expect(invoice.generateInvoiceNumber('evt_AbCdEf1234')).toBe('INV-ABCDEF12');
|
||||
});
|
||||
|
||||
test('falls back to NOEVENT for empty/missing input', () => {
|
||||
expect(invoice.generateInvoiceNumber('')).toBe('INV-NOEVENT');
|
||||
expect(invoice.generateInvoiceNumber(null)).toBe('INV-NOEVENT');
|
||||
expect(invoice.generateInvoiceNumber(undefined)).toBe('INV-NOEVENT');
|
||||
});
|
||||
|
||||
test('handles event id without prefix', () => {
|
||||
expect(invoice.generateInvoiceNumber('4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripControlChars', () => {
|
||||
test('replaces CRLF with single space (prevents SMTP header injection)', () => {
|
||||
const input = 'alice@example.com\r\nBcc: attacker@evil.com';
|
||||
const output = invoice.stripControlChars(input);
|
||||
expect(output).toBe('alice@example.com Bcc: attacker@evil.com');
|
||||
expect(output).not.toContain('\r');
|
||||
expect(output).not.toContain('\n');
|
||||
});
|
||||
|
||||
test('collapses whitespace runs', () => {
|
||||
expect(invoice.stripControlChars(' alice example ')).toBe('alice example');
|
||||
});
|
||||
|
||||
test('handles null/undefined gracefully', () => {
|
||||
expect(invoice.stripControlChars(null)).toBe('');
|
||||
expect(invoice.stripControlChars(undefined)).toBe('');
|
||||
});
|
||||
|
||||
test('preserves printable unicode (accents, emoji)', () => {
|
||||
expect(invoice.stripControlChars('Sami Ahmed 🚀')).toBe('Sami Ahmed 🚀');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
test('escapes all HTML metacharacters', () => {
|
||||
expect(invoice.escapeHtml('<script>alert(1)</script>'))
|
||||
.toBe('<script>alert(1)</script>');
|
||||
expect(invoice.escapeHtml(`"O'Brien & Sons"`))
|
||||
.toBe('"O'Brien & Sons"');
|
||||
});
|
||||
|
||||
test('handles null/undefined', () => {
|
||||
expect(invoice.escapeHtml(null)).toBe('');
|
||||
expect(invoice.escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderLicenseEmailHtml', () => {
|
||||
test('renders branded HTML with license code, invoice number, and price', () => {
|
||||
const { subject, html } = invoice.renderLicenseEmailHtml(BASE);
|
||||
expect(subject).toContain('DashCaddy Pro');
|
||||
expect(subject).toContain('30 days');
|
||||
expect(html).toContain('DC-PRO-30D-AB12CD34');
|
||||
expect(html).toContain('INV-4F2C9B3A');
|
||||
expect(html).toContain('$20.00');
|
||||
expect(html).toContain('Alice'); // first name from customerName
|
||||
expect(html).toContain('alice@example.com');
|
||||
// Brand colors must match the rest of DashCaddy
|
||||
expect(html).toContain('#09111f'); // bg
|
||||
expect(html).toContain('#7cf2c0'); // pro accent
|
||||
expect(html).toContain('#68a4ff'); // accent
|
||||
});
|
||||
|
||||
test('uses a friendly greeting when customerName is missing', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '' });
|
||||
expect(html).toContain('Hi there,');
|
||||
expect(html).not.toContain('Hi ,');
|
||||
});
|
||||
|
||||
test('does NOT include any CR or LF in user-controlled regions (CRLF injection defense)', () => {
|
||||
// Use NO-SPACE-after-colon payloads so that if `stripControlChars`
|
||||
// were deleted, the rendered output would contain "Bcc:attacker"
|
||||
// (header-injection survivors, no spaces between the colon and value).
|
||||
// The earlier version used "Bcc: attacker" (with space) which the
|
||||
// rendered output also had — the regex /Bcc:[^\s<]/ could not match
|
||||
// either way, so the test passed vacuously regardless of whether
|
||||
// sanitization actually ran.
|
||||
const malicious = {
|
||||
...BASE,
|
||||
email: 'alice@example.com\r\nBcc:attacker@evil.com',
|
||||
customerName: 'Eve\r\nBcc:eve@evil.com',
|
||||
code: 'X\r\nY',
|
||||
eventId: 'evt_\r\nfakeHeader:1',
|
||||
};
|
||||
const { html } = invoice.renderLicenseEmailHtml(malicious);
|
||||
// CRITICAL: no \r anywhere (template source has no \r).
|
||||
expect(html).not.toMatch(/\r/);
|
||||
// Extract each user-controlled region and assert no \n AND no
|
||||
// unbroken "Bcc:<value>" header-injection survivors. Each region
|
||||
// comes from the email/customerName/code/eventId values; if any
|
||||
// contains a \n OR a "Bcc:" without a space-after-colon, the test
|
||||
// fails. This is the strongest possible assertion: deleting
|
||||
// stripControlChars would break it immediately.
|
||||
const patterns = [
|
||||
{ name: 'email', re: /Email[^<]*<a[^>]+>([^<]+)<\/a>/ },
|
||||
{ name: 'name', re: /(?:Thanks for your purchase, |Hi )([^<!,]+)/ },
|
||||
{ name: 'code', re: /<div[^>]*word-break[^>]*>([^<]+)<\/div>/ },
|
||||
{ name: 'eventId', re: /Stripe event[^<]*<a[^>]+>([^<]+)<\/a>/ },
|
||||
];
|
||||
for (const { name, re } of patterns) {
|
||||
const m = html.match(re);
|
||||
if (m) {
|
||||
expect(m[1]).not.toMatch(/\n/);
|
||||
expect(m[1]).not.toMatch(/Bcc:[^\s<]/); // header-injection survivor
|
||||
expect(m[1]).not.toMatch(/fakeHeader:[^\s<]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('escapes HTML in customer name (XSS defense)', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({
|
||||
...BASE,
|
||||
customerName: '<script>alert(1)</script>',
|
||||
});
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
test('escapes HTML in email address', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({
|
||||
...BASE,
|
||||
email: '" onclick="alert(1)"@evil.com',
|
||||
});
|
||||
expect(html).not.toContain('onclick="alert(1)"');
|
||||
expect(html).toContain('"');
|
||||
});
|
||||
|
||||
test('falls back to productLabel from catalog when not provided', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({
|
||||
...BASE,
|
||||
productLabel: undefined,
|
||||
});
|
||||
expect(html).toContain('1 month'); // catalog label for pro-30d
|
||||
});
|
||||
|
||||
test('formats price as $XX.XX always with 2 decimals', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 9900 });
|
||||
expect(html).toContain('$99.00');
|
||||
});
|
||||
|
||||
test('non-USD currency shows native symbol (EUR, GBP, JPY)', () => {
|
||||
expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'EUR', amountCents: 5000 }).html)
|
||||
.toContain('€50.00');
|
||||
expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'GBP', amountCents: 3500 }).html)
|
||||
.toContain('£35.00');
|
||||
expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'JPY', amountCents: 200000 }).html)
|
||||
.toContain('¥2000.00');
|
||||
});
|
||||
|
||||
test('unknown currency falls back to ISO code suffix (never bare amount)', () => {
|
||||
// 9999 cents = $99.99 in major units
|
||||
const text = invoice.renderLicenseEmailText({ ...BASE, currency: 'XYZ', amountCents: 9999 });
|
||||
expect(text).toContain('99.99 XYZ');
|
||||
expect(text).not.toMatch(/99\.99\s*$/); // no trailing currency — must end with code
|
||||
});
|
||||
|
||||
test('rejects non-http(s) supportUrl schemes (javascript:, data:, file:)', () => {
|
||||
// Each of these would render in the customer's email client if it
|
||||
// slipped through. The bridge controls the value today, but defense-
|
||||
// in-depth: an allow-list is cheaper than an XSS incident.
|
||||
for (const badUrl of [
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'file:///etc/passwd',
|
||||
'vbscript:msgbox(1)',
|
||||
'ftp://example.com',
|
||||
]) {
|
||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, supportUrl: badUrl });
|
||||
expect(html).not.toContain('javascript:');
|
||||
expect(html).not.toContain('data:text/html');
|
||||
expect(html).not.toContain('file:///');
|
||||
expect(html).not.toContain('vbscript:');
|
||||
// Falls back to the canonical https URL.
|
||||
expect(html).toContain('https://dashcaddy.net');
|
||||
}
|
||||
});
|
||||
|
||||
test('long license code (>24 chars) wraps instead of overflowing PDF', async () => {
|
||||
// 50-char code would overflow the 484px Courier-Bold box at 13pt.
|
||||
const longCode = 'DC-PRO-30D-' + 'X'.repeat(40);
|
||||
const buf = await invoice.renderInvoicePdf({ ...BASE, code: longCode });
|
||||
expect(buf.length).toBeGreaterThan(1000);
|
||||
// PDFKit handles lineBreak:true by wrapping inside the box; we just
|
||||
// need to verify the PDF is structurally valid (parsed by pdf-parse).
|
||||
const pdfParse = require('pdf-parse');
|
||||
const { text } = await pdfParse(buf);
|
||||
// The key body should be in there somewhere — even if wrapped across
|
||||
// lines, at least part of the code is extractable.
|
||||
expect(text).toMatch(/DC-PRO-30D/);
|
||||
});
|
||||
|
||||
test('PDF Info Subject is constant (does NOT echo customer name or email)', async () => {
|
||||
// A customer-influenceable string in PDF metadata (visible in every
|
||||
// PDF reader's Properties panel) is a phishing-recon signal even
|
||||
// though it's not XSS-executable. The Subject field MUST be a
|
||||
// constant; the customer-identifying info lives in the visible body.
|
||||
const buf = await invoice.renderInvoicePdf({
|
||||
...BASE,
|
||||
customerName: '<script>alert(1)</script>',
|
||||
email: 'evil@attacker.com',
|
||||
});
|
||||
const pdfParse = require('pdf-parse');
|
||||
// Pass version option to extract metadata (some pdf-parse versions
|
||||
// require explicit hint to parse Info dictionary).
|
||||
const { metadata, text } = await pdfParse(buf, { version: 'default' });
|
||||
// If pdf-parse still doesn't extract metadata, fall back to scanning
|
||||
// the binary for the Subject string. Either way, the assertion holds.
|
||||
if (metadata) {
|
||||
expect(metadata.Subject).toBe('DashCaddy Pro invoice');
|
||||
} else {
|
||||
// The Subject is stored as an indirect object reference in the PDF;
|
||||
// it might not parse cleanly. Look for the constant in the binary
|
||||
// string form (PDFKit may encode it as UTF-16BE or octal escapes).
|
||||
const bin = buf.toString('binary');
|
||||
// The escaped form of "DashCaddy Pro invoice" in PDF literal strings
|
||||
// is the literal text wrapped in parentheses, possibly octal-escaped.
|
||||
// We just verify the email/HTML-payload is NOT in the metadata object
|
||||
// references — search for the literal Subject string body.
|
||||
const subjectObj = bin.match(/\/Subject\s*\(([^)]+)\)/);
|
||||
if (subjectObj) {
|
||||
expect(subjectObj[1]).not.toContain('evil@attacker.com');
|
||||
expect(subjectObj[1]).not.toContain('<script>');
|
||||
expect(subjectObj[1]).toMatch(/DashCaddy/);
|
||||
}
|
||||
}
|
||||
// The visible body can include the email (Bill To) but NOT the
|
||||
// XSS payload — that's escaped to text by escapeHtml() in renderInvoicePdf.
|
||||
expect(text).not.toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('rejects non-numeric amountCents (string "2000" would silently render $0.00)', () => {
|
||||
// STRING amount used to silently fall through to $0.00 because
|
||||
// Number.isFinite('2000') is false. Now we throw, surfacing the bug
|
||||
// at the bridge instead of shipping a $0 invoice to a paying customer.
|
||||
// We strip productId so the catalog fallback doesn't rescue the bad input.
|
||||
const { productId, ...baseNoProduct } = BASE;
|
||||
expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: '2000' }))
|
||||
.toThrow(/amountCents must be a positive integer/);
|
||||
});
|
||||
|
||||
test('rejects NaN, Infinity, negative, and zero amountCents', () => {
|
||||
const { productId, ...baseNoProduct } = BASE;
|
||||
for (const bad of [NaN, Infinity, -Infinity, -100, 0]) {
|
||||
expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: bad }))
|
||||
.toThrow(/amountCents must be a positive integer/);
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to catalog amount when amountCents is null AND productId resolves', () => {
|
||||
// Bridge contract: if amountCents is missing from the Stripe session
|
||||
// (older sessions, expand failure), we use the catalog's canonical
|
||||
// price rather than throwing. This is the recovery path.
|
||||
const html = invoice.renderLicenseEmailHtml({
|
||||
...BASE,
|
||||
productId: 'pro-30d',
|
||||
amountCents: null,
|
||||
}).html;
|
||||
// catalog says pro-30d = $20.00 (2000 cents)
|
||||
expect(html).toContain('$20.00');
|
||||
});
|
||||
|
||||
test('fractional cents are floored (no silent $0.01 from $0.005 rounding)', () => {
|
||||
// 2000.7 cents should render as $20.00 (floored). The bridge should
|
||||
// never send fractional cents in practice, but defense-in-depth.
|
||||
const html = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 2000.7 }).html;
|
||||
expect(html).toContain('$20.00');
|
||||
expect(html).not.toContain('$20.01');
|
||||
});
|
||||
|
||||
test('uses embedded SVG logo (works offline, no remote fetch)', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml(BASE);
|
||||
expect(html).toMatch(/src="data:image\/svg\+xml/);
|
||||
expect(html).not.toMatch(/src="https?:\/\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderLicenseEmailText', () => {
|
||||
test('includes license code, invoice #, and amount', () => {
|
||||
const text = invoice.renderLicenseEmailText(BASE);
|
||||
expect(text).toContain('DC-PRO-30D-AB12CD34');
|
||||
expect(text).toContain('INV-4F2C9B3A');
|
||||
expect(text).toContain('$20.00');
|
||||
expect(text).toContain('Stripe event');
|
||||
expect(text).toContain('evt_4f2c9b3a8b1d');
|
||||
});
|
||||
|
||||
test('uses first name from customerName when present', () => {
|
||||
const text = invoice.renderLicenseEmailText({
|
||||
...BASE,
|
||||
customerName: 'Alice Johnson',
|
||||
});
|
||||
expect(text.split('\n')[0]).toBe('Hi Alice,');
|
||||
});
|
||||
|
||||
test('falls back to "Hi there," when customerName missing', () => {
|
||||
const text = invoice.renderLicenseEmailText({ ...BASE, customerName: '' });
|
||||
expect(text.split('\n')[0]).toBe('Hi there,');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderInvoicePdf', () => {
|
||||
test('produces a valid PDF (magic bytes + non-trivial size)', async () => {
|
||||
const buf = await invoice.renderInvoicePdf(BASE);
|
||||
expect(buf.length).toBeGreaterThan(1000);
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
// PDF must end with %%EOF (or trailing newline + %%EOF)
|
||||
const tail = buf.slice(-32).toString('ascii');
|
||||
expect(tail).toContain('%%EOF');
|
||||
});
|
||||
|
||||
test('PDF contains the license code (visible text)', async () => {
|
||||
if (typeof pdfParse !== 'function') return; // soft skip if pdf-parse unavailable
|
||||
const buf = await invoice.renderInvoicePdf(BASE);
|
||||
const { text } = await pdfParse(buf);
|
||||
expect(text).toContain('DC-PRO-30D-AB12CD34');
|
||||
});
|
||||
|
||||
test('PDF contains the invoice number and amount', async () => {
|
||||
if (typeof pdfParse !== 'function') return;
|
||||
const buf = await invoice.renderInvoicePdf(BASE);
|
||||
const { text } = await pdfParse(buf);
|
||||
expect(text).toContain('INV-4F2C9B3A');
|
||||
expect(text).toContain('20.00');
|
||||
});
|
||||
|
||||
test('PDF includes customer name and email in bill-to', async () => {
|
||||
if (typeof pdfParse !== 'function') return;
|
||||
const buf = await invoice.renderInvoicePdf(BASE);
|
||||
const { text } = await pdfParse(buf);
|
||||
expect(text).toContain('Alice Johnson');
|
||||
expect(text).toContain('alice@example.com');
|
||||
});
|
||||
|
||||
test('rejects when code is missing', () => {
|
||||
// The invoice builder now returns a rejected promise for invalid input
|
||||
// (validated synchronously, surfaced via Promise.reject before any PDFKit
|
||||
// allocation). Use .rejects for the async side and the sync-style
|
||||
// expect().toThrow for the inline check.
|
||||
return expect(invoice.renderInvoicePdf({ ...BASE, code: '' }))
|
||||
.rejects.toThrow('code is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog integration', () => {
|
||||
test('all 4 catalog products render without throwing', async () => {
|
||||
const products = catalog.listProducts();
|
||||
for (const product of products) {
|
||||
const input = {
|
||||
...BASE,
|
||||
productId: product.id,
|
||||
productLabel: product.label,
|
||||
durationDays: product.durationDays,
|
||||
amountCents: product.amountCents,
|
||||
};
|
||||
const { subject, html } = invoice.renderLicenseEmailHtml(input);
|
||||
expect(subject).toContain(`${product.durationDays} days`);
|
||||
expect(html).toContain(`$${(product.amountCents / 100).toFixed(2)}`);
|
||||
|
||||
const pdf = await invoice.renderInvoicePdf(input);
|
||||
expect(pdf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
|
||||
if (typeof pdfParse === 'function') {
|
||||
const { text } = await pdfParse(pdf);
|
||||
expect(text).toContain(product.label);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('security: XSS via customer-controlled fields', () => {
|
||||
// These should all escape, not execute. We don't render the email
|
||||
// anywhere — this is just defense-in-depth at the template layer.
|
||||
test.each([
|
||||
['customerName', '<img src=x onerror=alert(1)>'],
|
||||
['email', '"><script>alert(1)</script>'],
|
||||
['code', '"><script>alert(1)</script>'],
|
||||
['eventId', '"><script>alert(1)</script>'],
|
||||
['sessionId', '"><script>alert(1)</script>'],
|
||||
])('field %s XSS payload is escaped', async (field, payload) => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({ ...BASE, [field]: payload });
|
||||
// The exact attack strings must not appear unescaped.
|
||||
expect(html).not.toContain(payload);
|
||||
// Escaped versions should be present (defense-in-depth visible).
|
||||
expect(html).toContain('<');
|
||||
});
|
||||
|
||||
test('img tag with onerror handler is fully escaped', () => {
|
||||
const { html } = invoice.renderLicenseEmailHtml({
|
||||
...BASE,
|
||||
customerName: '<img src=x onerror=alert(1)>',
|
||||
});
|
||||
// The payload is HTML-escaped: < and > become < / >
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
// The dangerous literal pattern must not appear.
|
||||
expect(html).not.toMatch(/<img[^>]+onerror/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -520,3 +520,221 @@ describe('stripe-license-bridge constants', () => {
|
||||
expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe-license-bridge invoice + email rendering (DC-058)', () => {
|
||||
// These tests verify the bridge actually invokes the invoice renderer
|
||||
// with the right inputs and that the SMTP send receives a multipart
|
||||
// body + a PDF attachment. Pairs with invoice.test.js (which tests the
|
||||
// rendering primitives in isolation).
|
||||
|
||||
test('passes customerName, sessionId, and amount through to the renderer', async () => {
|
||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
||||
injectSmtp(sendMailMock);
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
||||
|
||||
const event = buildSessionEvent({
|
||||
productId: 'pro-90d',
|
||||
customerEmail: 'alice@example.com',
|
||||
});
|
||||
// Add customer_details.name + amount_total in line_items[0] (like real Stripe).
|
||||
event.data.object.customer_details.name = 'Alice Johnson';
|
||||
event.data.object.line_items = {
|
||||
data: [{ amount_total: 5000, price: { id: 'price_90d_test', unit_amount: 5000 }, currency: 'usd' }],
|
||||
};
|
||||
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(result.body.deliveredVia).toBe('smtp');
|
||||
|
||||
// Verify the SMTP send was called with branded email + PDF attachment.
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||
expect(mailArgs.from).toBe('billing@dashcaddy.test');
|
||||
expect(mailArgs.to).toBe('alice@example.com');
|
||||
// Subject contains duration and "invoice".
|
||||
expect(mailArgs.subject).toContain('DashCaddy Pro');
|
||||
expect(mailArgs.subject).toContain('invoice');
|
||||
// HTML + text both present (multipart/alternative).
|
||||
expect(mailArgs.text).toBeDefined();
|
||||
expect(mailArgs.html).toBeDefined();
|
||||
expect(mailArgs.html).toContain('Hi Alice'); // first name from customer_details.name
|
||||
expect(mailArgs.html).toContain('INV-'); // invoice number
|
||||
expect(mailArgs.html).toContain('$50.00'); // 90d tier price
|
||||
// PDF attachment present.
|
||||
expect(Array.isArray(mailArgs.attachments)).toBe(true);
|
||||
expect(mailArgs.attachments).toHaveLength(1);
|
||||
expect(mailArgs.attachments[0].filename).toMatch(/^DashCaddy-Pro-Invoice-INV-.+\.pdf$/);
|
||||
expect(mailArgs.attachments[0].contentType).toBe('application/pdf');
|
||||
expect(mailArgs.attachments[0].encoding).toBe('base64');
|
||||
expect(mailArgs.attachments[0].content.length).toBeGreaterThan(1000); // real PDF
|
||||
// PDF magic bytes.
|
||||
expect(mailArgs.attachments[0].content.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
});
|
||||
|
||||
test('falls back to catalog amount when line_items are missing', async () => {
|
||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
||||
injectSmtp(sendMailMock);
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
||||
|
||||
const event = buildSessionEvent({ productId: 'pro-365d' });
|
||||
// Strip line_items entirely (simulates a webhook without expansion).
|
||||
delete event.data.object.line_items;
|
||||
delete event.data.object.amount_total;
|
||||
// Strip customer_details.name to verify "Hi there," fallback.
|
||||
delete event.data.object.customer_details.name;
|
||||
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
|
||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||
// Falls back to catalog: pro-365d is $99.00.
|
||||
expect(mailArgs.html).toContain('$99.00');
|
||||
expect(mailArgs.html).toContain('Hi there,');
|
||||
});
|
||||
|
||||
test('dev-console fallback logs invoice number + PDF size', async () => {
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
event.data.object.customer_details.name = 'Bob';
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.deliveredVia).toBe('dev-console');
|
||||
// We can't easily assert on log output from here, but the status proves
|
||||
// the dev-console path was taken. The log line includes pdfBytes —
|
||||
// covered indirectly by invoice.test.js verifying the PDF size.
|
||||
});
|
||||
|
||||
test('uses claim createdAt as issuedAt (not now) for stable retry semantics', async () => {
|
||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
||||
injectSmtp(sendMailMock);
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
||||
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
|
||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||
// The "Issued" line must reflect the claim's createdAt (which is when
|
||||
// the customer paid), not the moment we sent the email.
|
||||
expect(mailArgs.html).toMatch(/Issued[\s\S]*?\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC/);
|
||||
});
|
||||
|
||||
test('gracefully degrades to text-only email when PDF render fails', async () => {
|
||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
||||
injectSmtp(sendMailMock);
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
||||
|
||||
// Force PDF render to throw by passing an invalid issuedAt — this
|
||||
// exercises the try/catch around renderInvoicePdf and verifies the
|
||||
// bridge still sends a text+HTML email without the attachment.
|
||||
// (PDFKit auto-escapes most non-ASCII; lone surrogates no longer
|
||||
// throw on this PDFKit version. Bad dates remain a real crash path.)
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
// Override issuedAt to an invalid date via the bridge's deliverCode arg.
|
||||
// The bridge forwards this from the invoice module, which we can stub
|
||||
// at module level for this test.
|
||||
const invoiceMod = require('../../src/billing/invoice');
|
||||
const originalRender = invoiceMod.renderInvoicePdf;
|
||||
invoiceMod.renderInvoicePdf = jest.fn().mockRejectedValue(new Error('simulated PDF render failure'));
|
||||
try {
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const result = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.delivered).toBe(true);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const mailArgs = sendMailMock.mock.calls[0][0];
|
||||
// No PDF attachment when render failed.
|
||||
expect(mailArgs.attachments).toBeUndefined();
|
||||
// Text + HTML still sent (keygen mock uses DC-TEST-... in this suite).
|
||||
expect(mailArgs.text).toMatch(/DC-(PRO|TEST)-/);
|
||||
expect(mailArgs.html).toContain('DashCaddy');
|
||||
} finally {
|
||||
invoiceMod.renderInvoicePdf = originalRender;
|
||||
}
|
||||
});
|
||||
|
||||
test('replay (layer-1 event-id idempotency) does NOT re-render the invoice', async () => {
|
||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
||||
injectSmtp(sendMailMock);
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
||||
|
||||
const event = buildSessionEvent({ productId: 'pro-30d' });
|
||||
const { rawBody, signatureHeader } = buildSignedPayload(event);
|
||||
const sessionId = event.data.object.id;
|
||||
|
||||
// First delivery — generates a new license + invoice.
|
||||
const first = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(first.body.delivered).toBe(true);
|
||||
expect(first.body.codeId).toBeDefined();
|
||||
const firstCodeId = first.body.codeId;
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second delivery of the SAME event — should be deduplicated by event id
|
||||
// at the layer-1 check (bridge.checkEventIdempotency). SMTP must NOT be
|
||||
// called again because Stripe retrying the same event ID should never
|
||||
// re-send the invoice.
|
||||
const second = await bridge.handleWebhook({ rawBody, signatureHeader });
|
||||
expect(second.body.delivered).toBe(true);
|
||||
expect(second.body.deduplicated).toBe(true);
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('layer-2 (different event, same session) does NOT re-send the invoice', async () => {
|
||||
// Stripe can send BOTH `checkout.session.completed` AND
|
||||
// `checkout.session.async_payment_succeeded` for the same Checkout Session
|
||||
// (delayed-payment methods). Layer-1 dedup doesn't catch this because
|
||||
// the event IDs differ — only the session ID is the same. The bridge
|
||||
// MUST recognize that delivery already happened via the OTHER event and
|
||||
// ack 200 without re-sending.
|
||||
const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
|
||||
injectSmtp(sendMailMock);
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM = 'billing@dashcaddy.test';
|
||||
|
||||
const sessionId = `cs_test_layer2_${crypto.randomBytes(4).toString('hex')}`;
|
||||
const eventA = buildSessionEvent({
|
||||
productId: 'pro-30d',
|
||||
sessionId,
|
||||
eventId: `evt_A_${crypto.randomBytes(4).toString('hex')}`,
|
||||
});
|
||||
eventA.type = 'checkout.session.completed';
|
||||
|
||||
const eventB = buildSessionEvent({
|
||||
productId: 'pro-30d',
|
||||
sessionId,
|
||||
eventId: `evt_B_${crypto.randomBytes(4).toString('hex')}`,
|
||||
});
|
||||
eventB.type = 'checkout.session.async_payment_succeeded';
|
||||
|
||||
// First event: completes the payment, sends the invoice.
|
||||
const sigA = buildSignedPayload(eventA);
|
||||
const resultA = await bridge.handleWebhook({ rawBody: sigA.rawBody, signatureHeader: sigA.signatureHeader });
|
||||
expect(resultA.status).toBe(200);
|
||||
expect(resultA.body.delivered).toBe(true);
|
||||
expect(resultA.body.deduplicated).toBeUndefined();
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const firstInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
|
||||
|
||||
// Second event for the SAME session: must NOT re-send (different event
|
||||
// id, so layer-1 dedup doesn't catch it; layer-2 must).
|
||||
const sigB = buildSignedPayload(eventB);
|
||||
const resultB = await bridge.handleWebhook({ rawBody: sigB.rawBody, signatureHeader: sigB.signatureHeader });
|
||||
expect(resultB.status).toBe(200);
|
||||
expect(resultB.body.delivered).toBe(true);
|
||||
expect(resultB.body.deduplicated).toBe(true);
|
||||
// CRITICAL: only ONE SMTP call. Two invoice emails with different invoice
|
||||
// numbers for one charge is a financial-document bug.
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1);
|
||||
const secondInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
|
||||
expect(secondInvoice).toBe(firstInvoice); // same invoice number
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Tests for caddy-upstream-watcher.
|
||||
*
|
||||
* Mock-driven: we stub fs (for /etc/caddy/sites scan + state file) and http/https
|
||||
* (for the probe). Tests cover site parsing, probe happy/sad path, the 5-minute
|
||||
* "dead" threshold, mute toggle, and incident integration with healthChecker.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const Module = require('module');
|
||||
|
||||
// Mock fs with controllable behavior.
|
||||
const fsState = {
|
||||
files: {}, // path -> string content
|
||||
exists: {}, // path -> bool
|
||||
writeLog: [], // writes
|
||||
};
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const real = jest.requireActual('fs');
|
||||
return {
|
||||
...real,
|
||||
existsSync: jest.fn((p) => fsState.exists[p] !== undefined ? fsState.exists[p] : (fsState.files[p] !== undefined)),
|
||||
readFileSync: jest.fn((p) => {
|
||||
if (fsState.files[p] === undefined) {
|
||||
const e = new Error(`ENOENT: ${p}`);
|
||||
e.code = 'ENOENT';
|
||||
throw e;
|
||||
}
|
||||
return fsState.files[p];
|
||||
}),
|
||||
readdirSync: jest.fn((p) => Object.keys(fsState.files).filter(f => f.startsWith(p + '/')).map(f => f.substring(p.length + 1))),
|
||||
writeFileSync: jest.fn((p, content) => {
|
||||
fsState.writeLog.push({ p, content });
|
||||
fsState.files[p] = content;
|
||||
fsState.exists[p] = true;
|
||||
}),
|
||||
mkdirSync: jest.fn(),
|
||||
renameSync: jest.fn((src, dst) => {
|
||||
fsState.files[dst] = fsState.files[src];
|
||||
fsState.exists[dst] = true;
|
||||
delete fsState.files[src];
|
||||
delete fsState.exists[src];
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Mock http/https request to control probe responses.
|
||||
const probeQueue = []; // each entry: { kind: 'ok'|'err'|'timeout'|'code', statusCode? }
|
||||
jest.mock('http', () => ({
|
||||
request: jest.fn((opts, cb) => {
|
||||
const entry = probeQueue.shift() || { kind: 'ok', statusCode: 200 };
|
||||
const handlers = {};
|
||||
const res = {
|
||||
statusCode: entry.statusCode || 200,
|
||||
headers: { server: 'mock' },
|
||||
resume: () => {},
|
||||
on: (e, fn) => { handlers[e] = fn; }
|
||||
};
|
||||
const req = {
|
||||
on: jest.fn((e, fn) => { handlers[e] = fn; }),
|
||||
end: jest.fn(() => {
|
||||
if (entry.kind === 'err') {
|
||||
handlers.error && handlers.error(new Error(entry.message || 'connect ECONNREFUSED'));
|
||||
return;
|
||||
}
|
||||
if (entry.kind === 'timeout') {
|
||||
handlers.timeout && handlers.timeout();
|
||||
return;
|
||||
}
|
||||
cb(res);
|
||||
if (handlers.end) handlers.end();
|
||||
}),
|
||||
destroy: jest.fn()
|
||||
};
|
||||
return req;
|
||||
})
|
||||
}));
|
||||
jest.mock('https', () => ({
|
||||
request: jest.fn((opts, cb) => {
|
||||
const entry = probeQueue.shift() || { kind: 'ok', statusCode: 200 };
|
||||
const handlers = {};
|
||||
const res = {
|
||||
statusCode: entry.statusCode || 200,
|
||||
headers: { server: 'mock-https' },
|
||||
resume: () => {},
|
||||
on: (e, fn) => { handlers[e] = fn; }
|
||||
};
|
||||
const req = {
|
||||
on: jest.fn((e, fn) => { handlers[e] = fn; }),
|
||||
end: jest.fn(() => {
|
||||
if (entry.kind === 'err') {
|
||||
handlers.error && handlers.error(new Error(entry.message || 'TLS error'));
|
||||
return;
|
||||
}
|
||||
cb(res);
|
||||
if (handlers.end) handlers.end();
|
||||
}),
|
||||
destroy: jest.fn()
|
||||
};
|
||||
return req;
|
||||
})
|
||||
}));
|
||||
|
||||
// Reset fs mock state between tests.
|
||||
beforeEach(() => {
|
||||
fsState.files = {};
|
||||
fsState.exists = {};
|
||||
fsState.writeLog = [];
|
||||
probeQueue.length = 0;
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('CaddyUpstreamWatcher', () => {
|
||||
const SITES = '/etc/caddy/sites';
|
||||
const STATE = '/tmp/caddy-upstreams-test.json';
|
||||
|
||||
function seedSites(files) {
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fsState.files[SITES + '/' + name] = content;
|
||||
fsState.exists[SITES + '/' + name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function loadWatcher() {
|
||||
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
|
||||
process.env.CADDY_SITES_DIR = SITES;
|
||||
// Disable the singleton's auto-write so we can call _saveState manually.
|
||||
const mod = require('../src/monitoring/caddy-upstream-watcher');
|
||||
return { mod, w: mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod };
|
||||
}
|
||||
|
||||
test('parses reverse_proxy directives from /etc/caddy/sites/*', async () => {
|
||||
seedSites({
|
||||
'arch.sami': `arch.sami {\n reverse_proxy 100.120.159.34:5000 { health_uri /api/stats }\n}`,
|
||||
'appt.sami': `appt.sami {\n reverse_proxy http://100.81.59.99:5232\n}`,
|
||||
'zap.sami': `zap.sami {\n reverse_proxy 10.0.0.5:8080\n reverse_proxy 10.0.0.6:8080 # multiple upstreams in same site block\n}`
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const snap = w.snapshot();
|
||||
const hosts = snap.upstreams.map(u => u.host).sort();
|
||||
expect(hosts).toEqual(['10.0.0.5:8080', '10.0.0.6:8080', '100.120.159.34:5000', '100.81.59.99:5232']);
|
||||
expect(snap.upstreams.find(u => u.host === '100.120.159.34:5000').site).toBe('arch.sami');
|
||||
expect(snap.upstreams.find(u => u.host === '100.81.59.99:5232').site).toBe('appt.sami');
|
||||
});
|
||||
|
||||
test('ignores non-site files and unparseable entries', async () => {
|
||||
seedSites({
|
||||
'README.md': '# documentation\nreverse_proxy 1.2.3.4:9999\n', // not a site file
|
||||
'garbage.sami': 'not a caddyfile\n', // no reverse_proxy
|
||||
'good.sami': 'good.sami {\n reverse_proxy 1.2.3.4:9999\n}\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const hosts = w.snapshot().upstreams.map(u => u.host);
|
||||
expect(hosts).toEqual(['1.2.3.4:9999']);
|
||||
});
|
||||
|
||||
test('handles real prod-style filenames: zap.sami-ahmed.net, samitest.space, blocks.cryptographic-triangles.org', async () => {
|
||||
// These are the actual file names in production /etc/caddy/sites/ —
|
||||
// extension is .net / .space / .org, NOT .sami/.caddy/.conf. The old
|
||||
// file-extension filter would skip them silently.
|
||||
seedSites({
|
||||
'zap.sami-ahmed.net': 'zap.sami-ahmed.net {\n reverse_proxy localhost:8088\n}\n',
|
||||
'samitest.space': 'samitest.space {\n reverse_proxy 100.120.159.34:8080 {}\n}\n',
|
||||
'blocks.cryptographic-triangles.org': 'blocks.cryptographic-triangles.org {\n\treverse_proxy localhost:3052 {}\n}\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const snap = w.snapshot();
|
||||
const byHost = Object.fromEntries(snap.upstreams.map(u => [u.host, u.site]));
|
||||
expect(byHost['localhost:8088']).toBe('zap.sami-ahmed.net');
|
||||
expect(byHost['100.120.159.34:8080']).toBe('samitest.space');
|
||||
expect(byHost['localhost:3052']).toBe('blocks.cryptographic-triangles.org');
|
||||
});
|
||||
|
||||
test('drops upstreams that disappear from the sites dir', async () => {
|
||||
seedSites({
|
||||
'arch.sami': 'arch.sami {\n reverse_proxy 1.1.1.1:5000\n}\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
expect(w.upstreams.size).toBe(1);
|
||||
fsState.files = {}; // wipe
|
||||
fsState.exists = {};
|
||||
await w.scanSites();
|
||||
expect(w.upstreams.size).toBe(0);
|
||||
});
|
||||
|
||||
test('healthy probe updates state and does not open an incident', async () => {
|
||||
seedSites({ 'good.sami': 'good.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'ok', statusCode: 200 });
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = { createIncident: jest.fn(), incidents: [] };
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
await w._probeOne(w.upstreams.values().next().value);
|
||||
const snap = w.snapshot();
|
||||
expect(snap.upstreams[0].status).toBe('up');
|
||||
expect(snap.upstreams[0].lastSuccessAt).toBeTruthy();
|
||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth-walled 4xx counts as healthy (proves the upstream answered)', async () => {
|
||||
seedSites({ 'auth.sami': 'auth.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'ok', statusCode: 401 });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
await w._probeOne(w.upstreams.values().next().value);
|
||||
expect(w.snapshot().upstreams[0].status).toBe('up');
|
||||
});
|
||||
|
||||
test('first failure flips status to down but does NOT open an incident (under 5min)', async () => {
|
||||
seedSites({ 'bad.sami': 'bad.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'err', message: 'connect ECONNREFUSED' });
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = { createIncident: jest.fn(), incidents: [] };
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
u.lastSuccessAt = new Date(Date.now() - 30000).toISOString(); // 30s ago it was healthy
|
||||
await w._probeOne(u);
|
||||
const snap = w.snapshot();
|
||||
expect(snap.upstreams[0].status).toBe('down');
|
||||
expect(snap.upstreams[0].failingForMs).toBeLessThan(5 * 60 * 1000);
|
||||
expect(fakeHealthChecker.createIncident).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('after 5 minutes of consecutive failures an incident is opened', async () => {
|
||||
seedSites({ 'dead.sami': 'dead.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
const { w } = loadWatcher();
|
||||
const incidents = [];
|
||||
const fakeHealthChecker = {
|
||||
createIncident: jest.fn((serviceId, type, message, status) => {
|
||||
incidents.push({ serviceId, type, message, status });
|
||||
}),
|
||||
incidents: []
|
||||
};
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
// Simulate lastSuccessAt being 6 minutes ago so failingForMs exceeds DEAD_AFTER_MS.
|
||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledTimes(1);
|
||||
expect(fakeHealthChecker.createIncident.mock.calls[0][1]).toBe('caddy-upstream-dead');
|
||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not duplicate incidents for the same upstream', async () => {
|
||||
seedSites({ 'dead.sami': 'dead.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
// Queue up 3 errors so each probe fails.
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' });
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = {
|
||||
createIncident: jest.fn(),
|
||||
incidents: []
|
||||
};
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
await w._probeOne(u);
|
||||
await w._probeOne(u);
|
||||
expect(fakeHealthChecker.createIncident).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('recovery resolves the open incident after RESOLVED_AFTER_MS', async () => {
|
||||
seedSites({ 'flap.sami': 'flap.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
probeQueue.push({ kind: 'err', message: 'i/o timeout' }); // trip dead
|
||||
probeQueue.push({ kind: 'ok', statusCode: 200 }); // recovery
|
||||
const { w } = loadWatcher();
|
||||
const fakeHealthChecker = {
|
||||
createIncident: jest.fn(),
|
||||
resolveIncident: jest.fn(),
|
||||
incidents: []
|
||||
};
|
||||
w.healthChecker = fakeHealthChecker;
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
// Trip the dead state
|
||||
u.lastSuccessAt = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(true);
|
||||
// Simulate a recovery — lastFailureAt is 2 min ago, now healthy
|
||||
u.lastFailureAt = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
||||
await w._probeOne(u);
|
||||
expect(fakeHealthChecker.resolveIncident).toHaveBeenCalledTimes(1);
|
||||
expect(w.openIncidents.has('1.1.1.1:80')).toBe(false);
|
||||
});
|
||||
|
||||
test('mute suppresses probing and hides upstream in snapshot status', async () => {
|
||||
seedSites({ 'noisy.sami': 'noisy.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
w.setMuted('1.1.1.1:80', true);
|
||||
expect(w.isMuted('1.1.1.1:80')).toBe(true);
|
||||
const snap = w.snapshot();
|
||||
expect(snap.upstreams[0].status).toBe('muted');
|
||||
expect(snap.upstreams[0].muted).toBe(true);
|
||||
// probe tick should skip muted
|
||||
await w._tick();
|
||||
// lastCheckedAt should NOT have advanced because no probe was issued
|
||||
expect(snap.upstreams[0].lastCheckedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('unmute resets failure counters so a recently-recovered upstream is not immediately re-incidented', async () => {
|
||||
seedSites({ 'flap.sami': 'flap.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const u = w.upstreams.values().next().value;
|
||||
u.consecutiveFailures = 42;
|
||||
u.lastError = 'old failure';
|
||||
u.lastFailureAt = new Date().toISOString();
|
||||
u.status = 'down';
|
||||
w.setMuted('1.1.1.1:80', true);
|
||||
w.setMuted('1.1.1.1:80', false);
|
||||
expect(u.consecutiveFailures).toBe(0);
|
||||
expect(u.status).toBe('unknown');
|
||||
expect(u.lastError).toBeNull();
|
||||
});
|
||||
|
||||
test('snapshot sorts dead > down > muted > up > unknown', async () => {
|
||||
seedSites({
|
||||
'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n',
|
||||
'b.sami': 'b.sami { reverse_proxy 2.2.2.2:80 }\n',
|
||||
'c.sami': 'c.sami { reverse_proxy 3.3.3.3:80 }\n',
|
||||
'd.sami': 'd.sami { reverse_proxy 4.4.4.4:80 }\n',
|
||||
'e.sami': 'e.sami { reverse_proxy 5.5.5.5:80 }\n'
|
||||
});
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
const all = Array.from(w.upstreams.values());
|
||||
// 1.1.1.1:80 -> up (just succeeded)
|
||||
all.find(u => u.host === '1.1.1.1:80').status = 'up';
|
||||
all.find(u => u.host === '1.1.1.1:80').lastSuccessAt = new Date().toISOString();
|
||||
// 2.2.2.2:80 -> down (recent — last success 30s ago)
|
||||
all.find(u => u.host === '2.2.2.2:80').status = 'down';
|
||||
all.find(u => u.host === '2.2.2.2:80').lastSuccessAt = new Date(Date.now() - 30000).toISOString();
|
||||
// 3.3.3.3:80 -> muted
|
||||
w.muted.add('3.3.3.3:80');
|
||||
// 4.4.4.4:80 -> dead (last success 7min ago, never recovered)
|
||||
const dead = all.find(u => u.host === '4.4.4.4:80');
|
||||
dead.status = 'down';
|
||||
dead.lastSuccessAt = new Date(Date.now() - 7 * 60 * 1000).toISOString();
|
||||
// 5.5.5.5:80 -> unknown (no probes yet)
|
||||
const snap = w.snapshot();
|
||||
const order = snap.upstreams.map(u => u.host);
|
||||
// Expected: dead first, then down, then muted, then up, then unknown
|
||||
expect(order).toEqual(['4.4.4.4:80', '2.2.2.2:80', '3.3.3.3:80', '1.1.1.1:80', '5.5.5.5:80']);
|
||||
});
|
||||
|
||||
test('persists muted list to state file', async () => {
|
||||
seedSites({ 'a.sami': 'a.sami { reverse_proxy 1.1.1.1:80 }\n' });
|
||||
const { w } = loadWatcher();
|
||||
await w.scanSites();
|
||||
w.setMuted('1.1.1.1:80', true);
|
||||
// _saveState writes to STATE + '.tmp' then renames. Look for the .tmp
|
||||
// write since that's the actual writeFileSync call (rename is silent).
|
||||
const writes = fsState.writeLog.filter(w => w.p === STATE + '.tmp' || w.p === STATE);
|
||||
expect(writes.length).toBeGreaterThan(0);
|
||||
const last = writes[writes.length - 1];
|
||||
const data = JSON.parse(last.content);
|
||||
expect(data.muted).toContain('1.1.1.1:80');
|
||||
});
|
||||
|
||||
test('reload from state file restores muted list', async () => {
|
||||
// Pre-seed a state file with a muted host
|
||||
fsState.files[STATE] = JSON.stringify({
|
||||
muted: ['99.99.99.99:80'],
|
||||
upstreams: { '99.99.99.99:80': { ip: '99.99.99.99', port: '80', site: 'old.sami', siteFile: 'old.sami' } }
|
||||
});
|
||||
fsState.exists[STATE] = true;
|
||||
// And the matching site file
|
||||
seedSites({ 'old.sami': 'old.sami { reverse_proxy 99.99.99.99:80 }\n' });
|
||||
|
||||
process.env.CADDY_UPSTREAMS_STATE_FILE = STATE;
|
||||
process.env.CADDY_SITES_DIR = SITES;
|
||||
const mod = require('../src/monitoring/caddy-upstream-watcher');
|
||||
const w = mod.CaddyUpstreamWatcher ? new mod.CaddyUpstreamWatcher() : mod;
|
||||
expect(w.isMuted('99.99.99.99:80')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* DC-048 — disk-settings-loader unit tests
|
||||
*
|
||||
* Covers:
|
||||
* - applies persisted values to process.env (happy path)
|
||||
* - explicit process.env wins over persisted file
|
||||
* - missing file → no-op, no throw
|
||||
* - malformed JSON → no throw, engine defaults preserved
|
||||
* - non-numeric values rejected, not silently applied
|
||||
* - empty/null/undefined values skipped
|
||||
* - idempotent across calls (once-guard)
|
||||
* - all six mapped keys land in env when persisted
|
||||
*
|
||||
* Run with: npx jest __tests__/disk-settings-loader.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Snapshot env at module load so we can restore in afterEach. We always
|
||||
// UNSET the loader-managed keys (HEALTH_*, AUDIT_*, BACKUP_*, CONTAINER_STATS_*)
|
||||
// at the start of each test, regardless of whether they were set at
|
||||
// snapshot time, because the loader mutates process.env and stale values
|
||||
// from prior tests would silently change behavior.
|
||||
const LOADER_KEYS = [
|
||||
'HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES', 'HEALTH_HISTORY_RETENTION',
|
||||
'AUDIT_MAX_ENTRIES', 'BACKUP_MAX_STORAGE_BYTES', 'CONTAINER_STATS_MAX_ENTRIES',
|
||||
];
|
||||
const ORIGINAL_ENV = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([k]) => LOADER_KEYS.includes(k) || k === 'DATA_DIR'),
|
||||
);
|
||||
|
||||
function restoreEnv() {
|
||||
// Loader-managed keys: ALWAYS reset to ORIGINAL_ENV state (or undefined).
|
||||
// This is critical — without it, env vars set by a prior test would leak
|
||||
// into the next test as "env-already-set" and the loader would skip
|
||||
// values that the test expects to be applied.
|
||||
for (const k of LOADER_KEYS) {
|
||||
if (ORIGINAL_ENV[k] === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = ORIGINAL_ENV[k];
|
||||
}
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
}
|
||||
|
||||
// Temp data dir for filesystem-driven tests.
|
||||
const TMP_DATA_DIR = '/tmp/dashcaddy-disk-settings-loader-test';
|
||||
function makeDataDir() {
|
||||
try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
||||
fs.mkdirSync(TMP_DATA_DIR, { recursive: true });
|
||||
}
|
||||
function writePersisted(obj) {
|
||||
fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), JSON.stringify(obj));
|
||||
}
|
||||
|
||||
describe('disk-settings-loader', () => {
|
||||
beforeEach(() => {
|
||||
restoreEnv();
|
||||
makeDataDir();
|
||||
// Wipe the once-guard between tests so each case sees a fresh loader run.
|
||||
// We must require the module AFTER clearing the cache.
|
||||
delete require.cache[require.resolve('../src/config/disk-settings-loader')];
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
loader._resetForTesting();
|
||||
// Force hasRun reset (jest's module loader is not always cleared by the
|
||||
// require.cache delete — explicit call is the contract for the loader).
|
||||
// Note: loader._resetForTesting is the authoritative reset path.
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnv();
|
||||
try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ }
|
||||
});
|
||||
|
||||
it('applies all six persisted values to process.env', () => {
|
||||
writePersisted({
|
||||
healthCheckInterval: 45000,
|
||||
healthMaxEntries: 750,
|
||||
healthRetentionDays: 14,
|
||||
statsMaxEntries: 800,
|
||||
auditMaxEntries: 1500,
|
||||
backupMaxStorageBytes: 2147483648,
|
||||
});
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
|
||||
expect(result.applied).toHaveLength(6);
|
||||
expect(process.env.HEALTH_CHECK_INTERVAL).toBe('45000');
|
||||
expect(process.env.HEALTH_MAX_ENTRIES).toBe('750');
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
||||
expect(process.env.CONTAINER_STATS_MAX_ENTRIES).toBe('800');
|
||||
expect(process.env.AUDIT_MAX_ENTRIES).toBe('1500');
|
||||
expect(process.env.BACKUP_MAX_STORAGE_BYTES).toBe('2147483648');
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not throw when disk-settings.json is missing', () => {
|
||||
// TMP_DATA_DIR exists but no disk-settings.json inside it.
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
||||
const result = loader({ dataDir: TMP_DATA_DIR }); // idempotent
|
||||
expect(result.applied).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not throw on malformed JSON; logs to stderr', () => {
|
||||
fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), '{ this is not json');
|
||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(result.applied).toEqual([]);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WARN: failed to parse'),
|
||||
);
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('explicit process.env wins over persisted file', () => {
|
||||
process.env.HEALTH_HISTORY_RETENTION = '90';
|
||||
writePersisted({
|
||||
healthRetentionDays: 7,
|
||||
healthMaxEntries: 999,
|
||||
});
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('90'); // unchanged
|
||||
expect(process.env.HEALTH_MAX_ENTRIES).toBe('999'); // applied
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ envKey: 'HEALTH_HISTORY_RETENTION', reason: 'env-already-set' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects non-numeric values for numeric fields', () => {
|
||||
writePersisted({
|
||||
healthCheckInterval: 'fast', // not numeric
|
||||
healthMaxEntries: '500x', // not numeric
|
||||
healthRetentionDays: 14, // valid
|
||||
auditMaxEntries: null, // silently skipped (null)
|
||||
backupMaxStorageBytes: '', // silently skipped (empty)
|
||||
});
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
|
||||
// Only the valid value lands in `applied`.
|
||||
expect(result.applied.map((a) => a.envKey)).toEqual(['HEALTH_HISTORY_RETENTION']);
|
||||
// Non-numeric values appear in `skipped` with reason='non-numeric'.
|
||||
// null and '' are silently filtered (treated as "field not present").
|
||||
expect(result.skipped.map((s) => s.envKey).sort()).toEqual(
|
||||
['HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES'].sort(),
|
||||
);
|
||||
expect(result.skipped.every((s) => s.reason === 'non-numeric')).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces numeric strings (e.g. "14") to integer strings', () => {
|
||||
writePersisted({ healthRetentionDays: '14' });
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
||||
// Must be an integer-formatted string (not "14.7", "14x", etc.)
|
||||
expect(Number.isInteger(parseInt(process.env.HEALTH_HISTORY_RETENTION, 10))).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent across multiple calls (once-guard)', () => {
|
||||
writePersisted({ healthRetentionDays: 7 });
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const first = loader({ dataDir: TMP_DATA_DIR });
|
||||
const second = loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(first.applied).toHaveLength(1);
|
||||
expect(second.applied).toEqual([]);
|
||||
expect(second.alreadyRun).toBe(true);
|
||||
});
|
||||
|
||||
it('skips unknown fields without crashing', () => {
|
||||
writePersisted({
|
||||
healthRetentionDays: 14,
|
||||
unknownField: 'whatever',
|
||||
anotherUnknown: { nested: true },
|
||||
});
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow();
|
||||
expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14');
|
||||
});
|
||||
|
||||
it('returns a summary object with source path', () => {
|
||||
writePersisted({ healthRetentionDays: 14 });
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
const result = loader({ dataDir: TMP_DATA_DIR });
|
||||
expect(result.source).toBe(path.join(TMP_DATA_DIR, 'disk-settings.json'));
|
||||
expect(result.alreadyRun).toBe(false);
|
||||
});
|
||||
|
||||
it('writes a boot summary to stderr when no logger is provided', () => {
|
||||
writePersisted({ healthRetentionDays: 14, healthMaxEntries: 999 });
|
||||
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
const loader = require('../src/config/disk-settings-loader');
|
||||
loader({ dataDir: TMP_DATA_DIR }); // no logger passed
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[disk-settings-loader\] rehydrated 2 setting/),
|
||||
);
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Unit tests for the fixed /api/v1/error-logs parser
|
||||
// (route /opt/dashcaddy/dashcaddy-api/routes/errorlogs.js)
|
||||
//
|
||||
// Background: the prior implementation split on '='.repeat(80) but the
|
||||
// unified logger writes \u2500 horizontal-rule separators. As a result
|
||||
// every modal-open returned ZERO entries — same class of silent bug as
|
||||
// DC-050 (audit log). These tests pin the new behavior so future refactors
|
||||
// can't reintroduce it.
|
||||
|
||||
const { parseEntries, readTailBytes, MAX_TAIL } = require('../routes/errorlogs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
|
||||
const SEP = '\n' + '\u2500'.repeat(72) + '\n';
|
||||
|
||||
function buildLog(entries) {
|
||||
return entries.map((e, i) => {
|
||||
const head = `[${e.timestamp}] [${e.level}] ${e.context}: ${e.message}`;
|
||||
return head + (e.details ? '\n' + e.details : '') + SEP;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
describe('errorlogs parser (DC-051)', () => {
|
||||
test('parses single entry with U+2500 separator', () => {
|
||||
const text = buildLog([{
|
||||
timestamp: '2026-08-16T23:13:14.123Z',
|
||||
level: 'ERR',
|
||||
context: '/api/v1/templates',
|
||||
message: 'Route GET /v1/templates not found',
|
||||
details: 'NotFoundError: Route GET /v1/templates not found\n at notFoundHandler (/app/src/utilities/error-handler.js:71:8)',
|
||||
}]);
|
||||
const out = parseEntries(text);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({
|
||||
timestamp: '2026-08-16T23:13:14.123Z',
|
||||
level: 'ERR',
|
||||
context: '/api/v1/templates',
|
||||
message: 'Route GET /v1/templates not found',
|
||||
});
|
||||
expect(out[0].details).toContain('notFoundHandler');
|
||||
expect(out[0].details).not.toContain('\u2500');
|
||||
});
|
||||
|
||||
test('returns multiple entries in order, ignoring separator residue', () => {
|
||||
const text = buildLog([
|
||||
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'first' },
|
||||
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'WRN', context: 'b', message: 'second' },
|
||||
{ timestamp: '2026-08-16T23:02:00.000Z', level: 'INF', context: 'c', message: 'third', details: 'extra' },
|
||||
]);
|
||||
const out = parseEntries(text);
|
||||
expect(out.map(e => e.context)).toEqual(['a', 'b', 'c']);
|
||||
expect(out[1].level).toBe('WRN');
|
||||
expect(out[2].details).toBe('extra');
|
||||
});
|
||||
|
||||
test('skips malformed lines without throwing', () => {
|
||||
const text = 'this is not a log entry\n' + SEP + '[2026-08-16T23:00:00.000Z] [ERR] x: y\n' + SEP;
|
||||
const out = parseEntries(text);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].message).toBe('y');
|
||||
});
|
||||
|
||||
test('empty input returns empty array', () => {
|
||||
expect(parseEntries('')).toEqual([]);
|
||||
expect(parseEntries(' \n\n ')).toEqual([]);
|
||||
});
|
||||
|
||||
test('regression: would have returned 0 entries under the OLD splitter', () => {
|
||||
// Old impl: text.split('='.repeat(80)).filter(...). That produced one
|
||||
// big block, parser rejected all headers, returned ZERO entries. New
|
||||
// impl must NOT regress to that behavior on a real-format log.
|
||||
const text = buildLog([
|
||||
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'm' },
|
||||
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'ERR', context: 'b', message: 'm' },
|
||||
]);
|
||||
// Sanity: the old split would produce 1 block (no '=' in the text).
|
||||
expect(text.split('='.repeat(80))).toHaveLength(1);
|
||||
// New parser must surface both entries.
|
||||
expect(parseEntries(text)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('MAX_TAIL is bounded (>=100, <=1000) — prevents unbounded read', () => {
|
||||
expect(MAX_TAIL).toBeGreaterThanOrEqual(100);
|
||||
expect(MAX_TAIL).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorlogs readTailBytes (DC-051)', () => {
|
||||
let tmpFile;
|
||||
beforeAll(async () => {
|
||||
tmpFile = path.join(os.tmpdir(), `dc-051-errorlog-${process.pid}.log`);
|
||||
const entries = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
entries.push({
|
||||
timestamp: `2026-08-16T23:${String(i % 60).padStart(2,'0')}:00.000Z`,
|
||||
level: i % 2 === 0 ? 'ERR' : 'WRN',
|
||||
context: `ctx-${i}`,
|
||||
message: `message body ${i}`,
|
||||
details: i % 3 === 0 ? `stack for ${i}` : null,
|
||||
});
|
||||
}
|
||||
await fsp.writeFile(tmpFile, buildLog(entries));
|
||||
});
|
||||
afterAll(async () => {
|
||||
try { await fsp.unlink(tmpFile); } catch {}
|
||||
});
|
||||
|
||||
test('returns parsed entries within the byte budget', async () => {
|
||||
const { text, totalSize, truncated } = await readTailBytes(tmpFile, 4 * 1024);
|
||||
expect(typeof totalSize).toBe('number');
|
||||
expect(typeof truncated).toBe('boolean');
|
||||
const parsed = parseEntries(text);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
// Should never include partial first line — every parsed entry has a real timestamp.
|
||||
for (const e of parsed) {
|
||||
expect(e.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
}
|
||||
});
|
||||
|
||||
test('truncates when file exceeds byte budget', async () => {
|
||||
const stat = await fsp.stat(tmpFile);
|
||||
const smallBudget = Math.floor(stat.size / 4);
|
||||
const { truncated } = await readTailBytes(tmpFile, smallBudget);
|
||||
expect(truncated).toBe(true);
|
||||
});
|
||||
|
||||
test('does not truncate when file fits within byte budget', async () => {
|
||||
const stat = await fsp.stat(tmpFile);
|
||||
const bigBudget = stat.size * 2;
|
||||
const { truncated } = await readTailBytes(tmpFile, bigBudget);
|
||||
expect(truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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', 'xx')).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('xx')).toBe(false);
|
||||
expect(i18n.isSupported('klingon')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,14 +81,61 @@ 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('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', () => {
|
||||
|
||||
@@ -131,6 +131,7 @@ function readMountedRoutes() {
|
||||
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
|
||||
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
|
||||
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
|
||||
'routes/version.js', // apiRouter.use(versionRoute.buildRouter()) // bare mount — needed for /api/v1/version PUBLIC_ROUTES
|
||||
];
|
||||
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
|
||||
const prefixMap = {
|
||||
@@ -151,6 +152,12 @@ function readMountedRoutes() {
|
||||
try {
|
||||
factory = require(fullPath);
|
||||
} catch (e) { continue; }
|
||||
// Support object exports that expose buildRouter() (e.g. routes/version.js
|
||||
// exports { buildRouter, getVersion, getName }) — normalize to the factory
|
||||
// so the walker sees the routes it actually mounts in production.
|
||||
if (factory && typeof factory.buildRouter === 'function') {
|
||||
factory = factory.buildRouter;
|
||||
}
|
||||
if (typeof factory !== 'function') continue;
|
||||
let router;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Smoke tests for the audit-log viewer route (DC-050).
|
||||
*
|
||||
* Mirrors the caddy-upstreams.routes.test.js pattern: build the router with
|
||||
* stubbed dependencies, hit it via a tiny express app, assert the response
|
||||
* shape and the audit-logger calls.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const FIXTURE_ENTRIES = [
|
||||
{
|
||||
id: 'a1', timestamp: '2026-08-17T10:00:00.000Z', ip: '1.1.1.1',
|
||||
action: 'service.create', resource: 'plex',
|
||||
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
||||
},
|
||||
{
|
||||
id: 'a2', timestamp: '2026-08-17T11:00:00.000Z', ip: '1.1.1.1',
|
||||
action: 'auth.totp-setup', resource: 'u-1',
|
||||
details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
|
||||
},
|
||||
{
|
||||
id: 'a3', timestamp: '2026-08-17T12:00:00.000Z', ip: '2.2.2.2',
|
||||
action: 'auth.api-key-generate', resource: 'unknown',
|
||||
details: { userId: null }, outcome: 'failure',
|
||||
},
|
||||
{
|
||||
id: 'a4', timestamp: '2026-08-17T13:00:00.000Z', ip: '1.1.1.1',
|
||||
action: 'backup.execute', resource: 'all-apps',
|
||||
details: {}, outcome: 'success',
|
||||
},
|
||||
{
|
||||
id: 'a5', timestamp: '2026-08-17T14:00:00.000Z', ip: '3.3.3.3',
|
||||
action: 'caddy.add-site', resource: 'test.sami',
|
||||
details: {}, outcome: 'failure',
|
||||
},
|
||||
];
|
||||
|
||||
function buildFakeAuditLogger(entries = FIXTURE_ENTRIES) {
|
||||
return {
|
||||
query: jest.fn(async ({ limit = 50, offset = 0, action } = {}) => {
|
||||
let e = entries;
|
||||
if (action) e = e.filter((x) => x.action && x.action.startsWith(action));
|
||||
return e.slice(offset, offset + limit);
|
||||
}),
|
||||
clear: jest.fn(async () => {}),
|
||||
// log() is called by the DELETE handler to record `audit.clear` BEFORE
|
||||
// clearing — the act of clearing is itself an audit-worthy event.
|
||||
log: jest.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('routes/audit-log', () => {
|
||||
function buildRouter(logger) {
|
||||
const mod = require('../../routes/audit-log');
|
||||
return mod({
|
||||
asyncHandler: (fn) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
auditLogger: logger,
|
||||
});
|
||||
}
|
||||
|
||||
test('router builds with the expected paths', () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const router = buildRouter(logger);
|
||||
expect(router).toBeDefined();
|
||||
expect(typeof router.use).toBe('function');
|
||||
const paths = router.stack
|
||||
.filter((l) => l.route)
|
||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||
.flat();
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'GET /audit-logs',
|
||||
'GET /audit-logs/actions',
|
||||
'DELETE /audit-logs',
|
||||
]));
|
||||
});
|
||||
|
||||
test('GET /audit-logs returns all entries when no filters', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=10`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.entries).toHaveLength(5);
|
||||
expect(body.total).toBe(5);
|
||||
expect(body.hasMore).toBe(false);
|
||||
expect(body.filters).toEqual({ action: null, since: null, until: null, outcome: null });
|
||||
});
|
||||
|
||||
test('GET /audit-logs respects limit + offset', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=2&offset=0`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.entries).toHaveLength(2);
|
||||
expect(body.entries[0].id).toBe('a1');
|
||||
expect(body.hasMore).toBe(true);
|
||||
|
||||
const server2 = app.listen(0);
|
||||
const { port: port2 } = server2.address();
|
||||
const res2 = await fetch(`http://127.0.0.1:${port2}/audit-logs?limit=2&offset=4`);
|
||||
const body2 = await res2.json();
|
||||
server2.close();
|
||||
expect(body2.entries).toHaveLength(1);
|
||||
expect(body2.entries[0].id).toBe('a5');
|
||||
expect(body2.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
test('GET /audit-logs?action=auth filters server-side via auditLogger.query', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=auth`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.entries).toHaveLength(2);
|
||||
expect(body.entries.every((e) => e.action.startsWith('auth'))).toBe(true);
|
||||
// The action filter MUST be pushed down to the audit-logger so we don't
|
||||
// load the full 1000-entry store when the operator filters by category.
|
||||
expect(logger.query).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth' }));
|
||||
});
|
||||
|
||||
test('GET /audit-logs rejects unknown action prefix with 400', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=pwnz`);
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/action must be one of/);
|
||||
});
|
||||
|
||||
test('GET /audit-logs filters by since (date >= since)', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T13:00:00.000Z`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.entries).toHaveLength(2); // a4 + a5
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a4', 'a5']);
|
||||
});
|
||||
|
||||
test('GET /audit-logs filters by outcome=failure', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?outcome=failure`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.entries).toHaveLength(2); // a3 + a5
|
||||
expect(body.entries.every((e) => e.outcome === 'failure')).toBe(true);
|
||||
});
|
||||
|
||||
test('GET /audit-logs rejects since > until with 400', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T20:00:00Z&until=2026-08-17T10:00:00Z`);
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/since must be <= until/);
|
||||
});
|
||||
|
||||
test('GET /audit-logs rejects malformed ISO 8601 with 400', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=not-a-date`);
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/since must be ISO 8601/);
|
||||
});
|
||||
|
||||
test('GET /audit-logs caps limit at 500 (no DoS via huge page)', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=99999`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.limit).toBe(500);
|
||||
});
|
||||
|
||||
test('GET /audit-logs/actions returns distinct action prefixes', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.prefixes).toEqual(['auth', 'backup', 'caddy', 'service']);
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs requires confirm=CLEAR body', async () => {
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/confirm: "CLEAR"/);
|
||||
expect(logger.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs with confirm=CLEAR calls auditLogger.clear()', async () => {
|
||||
const logger = buildFakeAuditFixtureSafe();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.cleared).toBe(true);
|
||||
expect(logger.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('module.exports throws when auditLogger is missing query()', () => {
|
||||
const mod = require('../../routes/audit-log');
|
||||
expect(() => mod({ asyncHandler: (fn) => fn, auditLogger: {} }))
|
||||
.toThrow(/auditLogger with query/);
|
||||
});
|
||||
|
||||
// ── GLM round-1 defect regressions ───────────────────────────────────────
|
||||
|
||||
test('GET /audit-logs does NOT amputate the store when limit*5 < MAX_ENTRIES (cap-truncation fix)', async () => {
|
||||
// Round-1 [HIGH]: route previously fetched `limit * 5` entries from
|
||||
// the store and computed total/hasMore over that truncated slice.
|
||||
// With MAX_ENTRIES=1000 and limit=50, the cap was 250 — silently
|
||||
// hiding entries 251-1000. The fix fetches the full store (1000).
|
||||
const entries = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
timestamp: new Date(Date.parse('2026-08-17T00:00:00Z') + i * 1000).toISOString(),
|
||||
ip: '9.9.9.9',
|
||||
action: 'service.create',
|
||||
resource: `svc-${i}`,
|
||||
details: {},
|
||||
outcome: i % 3 === 0 ? 'failure' : 'success',
|
||||
}));
|
||||
const logger = buildFakeAuditLogger(entries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=50&offset=200`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.total).toBe(1000); // full store, not 250
|
||||
expect(body.hasMore).toBe(true); // still more after offset 200
|
||||
expect(body.truncated).toBe(true); // signal that store was at cap
|
||||
});
|
||||
|
||||
test('GET /audit-logs compares ISO timestamps numerically (lexicographic compare fix)', async () => {
|
||||
// Round-1 [MEDIUM]: '10:00:00.000Z' < '10:00:00Z' is false lexicographically
|
||||
// (the latter is a strict substring, breaking `>=`). Fix: use Date.parse().
|
||||
const fixedEntries = [
|
||||
{ id: 'b1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'b2', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
];
|
||||
const logger = buildFakeAuditLogger(fixedEntries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
// Same instant as b1 in a different ISO format — must be included.
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T10:00:00Z`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
test('GET /audit-logs accepts ISO with positive UTC offset (numeric compare fix)', async () => {
|
||||
const fixedEntries = [
|
||||
{ id: 'c1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'c2', timestamp: '2026-08-17T11:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'c3', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
];
|
||||
const logger = buildFakeAuditLogger(fixedEntries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
// 11:00+02:00 = 09:00Z. Filter for entries AFTER 09:00Z. Expect c1 + c2 + c3.
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T11:00:00%2B02:00`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.total).toBe(3);
|
||||
});
|
||||
|
||||
test('GET /audit-logs/actions only surfaces whitelisted prefixes', async () => {
|
||||
// Round-1 [LOW]: dropdown advertised prefixes (e.g. `logs`, `events`)
|
||||
// that GET /audit-logs?action=logs would then 400. Fix: intersect with
|
||||
// the whitelist before returning.
|
||||
const mixedEntries = [
|
||||
{ id: 'd1', timestamp: '2026-08-17T10:00:00Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'd2', timestamp: '2026-08-17T10:01:00Z', ip: '', action: 'logs.something', resource: '', details: {}, outcome: 'success' },
|
||||
{ id: 'd3', timestamp: '2026-08-17T10:02:00Z', ip: '', action: 'events.publish', resource: '', details: {}, outcome: 'success' },
|
||||
];
|
||||
const logger = buildFakeAuditLogger(mixedEntries);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.prefixes).toEqual(['service']); // logs/events filtered out
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs writes audit.clear BEFORE AND AFTER clear() — re-injection preserves the forensic breadcrumb', async () => {
|
||||
// GLM round-2 [MEDIUM]: a naive "log before clear()" self-erases —
|
||||
// clear() wipes the entry that was just written. Fix: log before
|
||||
// clear() (catches any failure path), then clear(), then log AGAIN
|
||||
// so the entry survives as the single row visible to the viewer.
|
||||
const logger = buildFakeAuditLogger();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
// log() runs TWICE — once before clear (catches failure paths) and
|
||||
// once after clear (re-injects the forensic breadcrumb).
|
||||
expect(logger.log).toHaveBeenCalledTimes(2);
|
||||
expect(logger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
action: 'audit.clear',
|
||||
resource: 'audit-log.json',
|
||||
outcome: 'success',
|
||||
}));
|
||||
expect(logger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
action: 'audit.clear',
|
||||
resource: 'audit-log.json',
|
||||
outcome: 'success',
|
||||
}));
|
||||
// Ordering: log → clear → log (second log runs AFTER clear).
|
||||
const logOrders = logger.log.mock.invocationCallOrder;
|
||||
const clearOrder = logger.clear.mock.invocationCallOrder[0];
|
||||
expect(logOrders[0]).toBeLessThan(clearOrder);
|
||||
expect(logOrders[1]).toBeGreaterThan(clearOrder);
|
||||
});
|
||||
|
||||
test('DELETE /audit-logs still calls clear() even if auditLogger.log() throws', async () => {
|
||||
// A failing audit-log write must NOT block the operator's clear.
|
||||
const logger = buildFakeAuditLogger();
|
||||
logger.log.mockRejectedValueOnce(new Error('disk full'));
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildRouter(logger));
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(logger.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Tiny helper — separated so the second clear test has a fresh mock.
|
||||
function buildFakeAuditFixtureSafe() {
|
||||
return buildFakeAuditLogger();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Smoke tests for the caddy-upstreams router.
|
||||
*
|
||||
* No jest.mock('fs') here — the route module needs a real express
|
||||
* context to load, and the watcher logic is tested separately in
|
||||
* caddy-upstream-watcher.test.js.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
|
||||
describe('routes/caddy-upstreams', () => {
|
||||
test('router builds with all expected paths and handlers', () => {
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
const fakeWatcher = {
|
||||
snapshot: jest.fn(() => ({ upstreams: [], config: {} }))
|
||||
};
|
||||
const fakeHealthChecker = { incidents: [] };
|
||||
|
||||
const router = mod({
|
||||
asyncHandler: (fn) => fn,
|
||||
caddyUpstreamWatcher: fakeWatcher,
|
||||
healthChecker: fakeHealthChecker
|
||||
});
|
||||
|
||||
expect(router).toBeDefined();
|
||||
expect(typeof router.use).toBe('function');
|
||||
|
||||
const paths = router.stack
|
||||
.filter((l) => l.route)
|
||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||
.flat();
|
||||
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'GET /caddy/upstreams',
|
||||
'GET /caddy/upstreams/incidents',
|
||||
'POST /caddy/upstreams/mute',
|
||||
'POST /caddy/upstreams/:host/mute',
|
||||
'POST /caddy/upstreams/:host/unmute'
|
||||
]));
|
||||
});
|
||||
|
||||
test('GET /caddy/upstreams responds with watcher snapshot', async () => {
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
const fakeSnapshot = { upstreams: [{ host: '1.1.1.1:80', status: 'up', muted: false }], config: {} };
|
||||
const fakeWatcher = { snapshot: jest.fn(() => fakeSnapshot) };
|
||||
const fakeHealthChecker = { incidents: [] };
|
||||
|
||||
// Build a tiny express app with the route + a shim success/error responder.
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.success = (data) => res.json({ success: true, ...data });
|
||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
||||
next();
|
||||
});
|
||||
app.use(mod({
|
||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
caddyUpstreamWatcher: fakeWatcher,
|
||||
healthChecker: fakeHealthChecker
|
||||
}));
|
||||
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams`);
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.upstreams).toEqual(fakeSnapshot.upstreams);
|
||||
});
|
||||
|
||||
test('POST /caddy/upstreams/mute with body {host, muted:"false"} does NOT mute (string coercion)', async () => {
|
||||
// Regression: bare route previously used `muted !== false` which muted
|
||||
// when muted was a string 'false' (because 'false' !== false). Fix
|
||||
// requires explicit `muted === false` to unmute.
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
const fakeSnapshot = { upstreams: [], config: {} };
|
||||
const fakeWatcher = {
|
||||
snapshot: jest.fn(() => fakeSnapshot),
|
||||
upstreams: new Map([['known:80', { host: 'known:80' }]]),
|
||||
setMuted: jest.fn(() => ({ host: 'known:80', muted: false }))
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.success = (data) => res.json({ success: true, ...data });
|
||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
||||
next();
|
||||
});
|
||||
app.use(mod({
|
||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
caddyUpstreamWatcher: fakeWatcher,
|
||||
healthChecker: { incidents: [] }
|
||||
}));
|
||||
// Error middleware MUST be registered AFTER routes so it actually catches.
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.statusCode === 400) {
|
||||
return res.status(400).json({ success: false, error: err.message });
|
||||
}
|
||||
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
||||
});
|
||||
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
|
||||
// String 'false' should NOT mute (should unmute or pass through)
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: 'known:80', muted: 'false' })
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(fakeWatcher.setMuted).toHaveBeenCalledWith('known:80', false);
|
||||
|
||||
// Unknown host should 400
|
||||
fakeWatcher.setMuted.mockClear();
|
||||
const res2 = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: 'not-a-real-host:80' })
|
||||
});
|
||||
const body2 = await res2.json();
|
||||
server.close();
|
||||
expect(res2.status).toBe(400);
|
||||
expect(body2.error).toMatch(/not a known upstream/);
|
||||
expect(fakeWatcher.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,23 +14,39 @@ function createI18nApp() {
|
||||
}
|
||||
|
||||
describe('DC-077: i18n Routes', () => {
|
||||
it('GET /i18n/languages returns 5 languages', async () => {
|
||||
it('GET /i18n/languages returns 31 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).toHaveLength(31);
|
||||
expect(res.body.default).toBe('en');
|
||||
});
|
||||
|
||||
it('GET /i18n/languages includes RTL flag for Arabic', async () => {
|
||||
it('GET /i18n/languages marks Arabic, Persian, and Urdu as RTL', async () => {
|
||||
const app = createI18nApp();
|
||||
const res = await request(app).get('/api/v1/i18n/languages');
|
||||
|
||||
const arabic = res.body.languages.find(l => l.code === 'ar');
|
||||
expect(arabic).toBeTruthy();
|
||||
expect(arabic.rtl).toBe(true);
|
||||
const rtl = (code) => {
|
||||
const entry = res.body.languages.find(l => l.code === code);
|
||||
expect(entry).toBeTruthy();
|
||||
expect(entry.name).not.toBe(code);
|
||||
return entry.rtl;
|
||||
};
|
||||
expect(rtl('ar')).toBe(true);
|
||||
expect(rtl('fa')).toBe(true);
|
||||
expect(rtl('ur')).toBe(true);
|
||||
const english = res.body.languages.find(l => l.code === 'en');
|
||||
expect(english.rtl).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /i18n/translations/fa returns Persian strings, not raw English', async () => {
|
||||
const app = createI18nApp();
|
||||
const res = await request(app).get('/api/v1/i18n/translations/fa');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.translations['action.open']).not.toBe('Open');
|
||||
expect(res.body.translations['filter.online']).not.toBe('Online');
|
||||
});
|
||||
|
||||
it('GET /i18n/translations/en returns English translations', async () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// This test mounts the EXACT version route module that production wires into
|
||||
// apiRouter via require('../routes/version') in src/app.js. There is no
|
||||
// duplicated handler — both production and this test resolve the same module.
|
||||
|
||||
describe('HTTP /api/v1/version route contract (real production module)', () => {
|
||||
let app;
|
||||
let versionModule;
|
||||
|
||||
beforeAll(() => {
|
||||
app = express();
|
||||
versionModule = require('../../routes/version');
|
||||
app.use('/api/v1', versionModule.buildRouter());
|
||||
});
|
||||
|
||||
it('returns package semver via the real version route module', async () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
const res = await request(app).get('/api/v1/version');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.version).toBe(pkg.version);
|
||||
expect(res.body.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(res.body.name).toBe('dashcaddy-api');
|
||||
expect(res.body.node).toMatch(/^v\d+/);
|
||||
expect(res.body.platform).toBe(process.platform);
|
||||
expect(res.body.arch).toBe(process.arch);
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
});
|
||||
|
||||
it('version module exports getVersion/getName/buildRouter', () => {
|
||||
expect(typeof versionModule.getVersion).toBe('function');
|
||||
expect(typeof versionModule.getName).toBe('function');
|
||||
expect(typeof versionModule.buildRouter).toBe('function');
|
||||
expect(versionModule.getVersion()).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it('src/app.js wires routes/version.js into the apiRouter', () => {
|
||||
const appSource = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'app.js'), 'utf8');
|
||||
expect(appSource).toMatch(/require\(['"]\.\.\/routes\/version['"]\)/);
|
||||
expect(appSource).toMatch(/versionRoute\.buildRouter\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const apiRoot = path.join(__dirname, '..');
|
||||
|
||||
describe('production version contract', () => {
|
||||
test('package semver is the source reported by the public version route', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(apiRoot, 'package.json'), 'utf8'));
|
||||
const app = fs.readFileSync(path.join(apiRoot, 'src', 'app.js'), 'utf8');
|
||||
expect(pkg.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
// The version route is now extracted to routes/version.js and wired in.
|
||||
expect(app).toMatch(/require\(['"]\.\.\/routes\/version['"]\)/);
|
||||
expect(app).toMatch(/versionRoute\.buildRouter\(\)/);
|
||||
});
|
||||
|
||||
test('production Docker image copies the manifest read by the route', () => {
|
||||
const dockerfile = fs.readFileSync(path.join(apiRoot, 'Dockerfile'), 'utf8');
|
||||
expect(dockerfile).toMatch(/^COPY package\.json \.\/$/m);
|
||||
expect(dockerfile).toMatch(/^RUN npm ci --omit=dev$/m);
|
||||
expect(dockerfile).not.toMatch(/^RUN npm install$/m);
|
||||
expect(dockerfile).toMatch(/^COPY src\/ \.\/src\/$/m);
|
||||
});
|
||||
|
||||
test('routes/version.js exports the production route module', () => {
|
||||
const versionRoute = require('../routes/version');
|
||||
expect(typeof versionRoute.buildRouter).toBe('function');
|
||||
expect(versionRoute.getVersion()).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
Generated
+1080
-162
File diff suppressed because it is too large.
Load diff
@@ -33,12 +33,13 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lru-cache": "^10.4.3",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^9.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"pdfkit": "^0.15.2",
|
||||
"png-to-ico": "^2.1.8",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"sharp": "^0.33.5",
|
||||
"sharp": "^0.35.3",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"webdav": "^5.7.1",
|
||||
@@ -47,6 +48,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.1",
|
||||
"jest": "^29.7.0",
|
||||
"pdf-parse": "^1.1.4",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^6.3.4"
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
|
||||
log.info('deploy', 'DashCA: Static site deployment completed successfully');
|
||||
} catch (error) {
|
||||
log.error('deploy', 'DashCA deployment error', { error: error.message });
|
||||
log.error('deploy', error, null, { note: 'DashCA deployment error' });
|
||||
throw new Error(`DashCA deployment failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
await portLockManager.releasePorts(lockId);
|
||||
log.info('deploy', 'Port locks released after error', { lockId });
|
||||
} catch (releaseError) {
|
||||
log.error('deploy', 'Failed to release port locks', { lockId, error: releaseError.message });
|
||||
log.error('deploy', releaseError, null, { note: 'Failed to release port locks', lockId });
|
||||
}
|
||||
}
|
||||
throw deployError;
|
||||
@@ -425,7 +425,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
} catch (error) {
|
||||
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||
const msg = error?.message || String(error || 'Unknown error');
|
||||
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||
log.error('deploy', error, null, { note: 'Deployment failed', appId });
|
||||
const template = ctx.APP_TEMPLATES[appId];
|
||||
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||
|
||||
@@ -297,7 +297,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
|
||||
// P0-5 fix: was `errorResponse(res, 500, err.message)` which leaks internal
|
||||
// error details (paths, stack traces, library error codes) to the client.
|
||||
// Log the actual error server-side and return a generic message.
|
||||
log.error('apps-revert', 'Revert failed', { error: err.message, stack: err.stack });
|
||||
log.error('apps-revert', err, null, { note: 'Revert failed', stack: err.stack });
|
||||
errorResponse(res, 500, 'Revert failed');
|
||||
}
|
||||
}, 'apps-revert'));
|
||||
|
||||
@@ -148,7 +148,7 @@ module.exports = function({
|
||||
}
|
||||
} catch (error) {
|
||||
results.caddy = `failed: ${error.message}`;
|
||||
log.error('caddy', 'Caddy update error', { error: error.message });
|
||||
log.error('caddy', error, null, { note: 'Caddy update error' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
||||
stream.on('error', () => resolve(null));
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('docker', 'Failed to get API key', { containerName, error: error.message });
|
||||
log.error('docker', error, null, { note: 'Failed to get API key', containerName });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
||||
stream.on('error', () => resolve(null));
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('docker', 'Failed to get Plex token', { error: error.message });
|
||||
log.error('docker', error, null, { note: 'Failed to get Plex token' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ module.exports = function({ docker, credentialManager, fetchT, log }) {
|
||||
const sessionCookie = setCookie.split(';')[0];
|
||||
return { cookie: sessionCookie, plexToken };
|
||||
} catch (e) {
|
||||
log.error('arr', 'Could not get Seerr session', { error: e.message });
|
||||
log.error('arr', e, null, { note: 'Could not get Seerr session' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Audit log viewer routes
|
||||
*
|
||||
* Exposes:
|
||||
* GET /api/v1/audit-logs — paginated audit entries (auth-gated)
|
||||
* GET /api/v1/audit-logs/actions — distinct action prefixes (for filter dropdowns)
|
||||
* DELETE /api/v1/audit-logs — clear the audit log (admin-gated)
|
||||
*
|
||||
* The frontend at status/js/audit-log.js already calls /api/v1/audit-logs
|
||||
* with {limit, offset, action=<prefix>}. Before this route existed the
|
||||
* frontend silently 404'd (see STATE.md Queue item #1, DC-050).
|
||||
*
|
||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
||||
*
|
||||
* @module routes/audit-log
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, errorResponse } = require('../src/utils/responses');
|
||||
|
||||
// Action prefixes that the dashboard's filter dropdown offers + that the
|
||||
// `action` query parameter will accept. Curated, NOT derived from current
|
||||
// log contents — see /audit-logs/actions for the live set.
|
||||
const ACTION_PREFIX_WHITELIST = [
|
||||
'service', 'container', 'caddy', 'dns', 'backup', 'config',
|
||||
'auth', 'totp', 'update', 'monitoring', 'site', 'arr', 'tailscale',
|
||||
];
|
||||
|
||||
const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
|
||||
|
||||
function parseInt10(value, fallback) {
|
||||
const n = parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function isValidActionPrefix(value) {
|
||||
return ACTION_PREFIX_WHITELIST.includes(value);
|
||||
}
|
||||
|
||||
function isValidIso(value) {
|
||||
if (typeof value !== 'string' || value.length < 10) return false;
|
||||
return ISO8601_RE.test(value);
|
||||
}
|
||||
|
||||
// Parse an ISO 8601 string into ms-since-epoch. Returns NaN for invalid
|
||||
// input — callers must pre-validate with isValidIso(). Used to compare
|
||||
// timestamps numerically (lexicographic compare breaks when the two
|
||||
// strings use different offset formats).
|
||||
function toEpochMs(iso) {
|
||||
const ms = Date.parse(iso);
|
||||
return ms;
|
||||
}
|
||||
|
||||
module.exports = function({ asyncHandler, auditLogger }) {
|
||||
if (!auditLogger || typeof auditLogger.query !== 'function') {
|
||||
throw new Error('audit-log route requires auditLogger with query()');
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /audit-logs?limit=50&offset=0&action=<prefix>&since=<iso>&until=<iso>&outcome=<success|failure>
|
||||
router.get('/audit-logs', asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(Math.max(parseInt10(req.query.limit, 50), 1), 500);
|
||||
const offset = Math.max(parseInt10(req.query.offset, 0), 0);
|
||||
const actionPrefix = typeof req.query.action === 'string' && req.query.action.length > 0
|
||||
? req.query.action
|
||||
: null;
|
||||
const sinceRaw = typeof req.query.since === 'string' && req.query.since.length > 0
|
||||
? req.query.since
|
||||
: null;
|
||||
const untilRaw = typeof req.query.until === 'string' && req.query.until.length > 0
|
||||
? req.query.until
|
||||
: null;
|
||||
const outcome = typeof req.query.outcome === 'string' && req.query.outcome.length > 0
|
||||
? req.query.outcome
|
||||
: null;
|
||||
|
||||
if (actionPrefix !== null && !isValidActionPrefix(actionPrefix)) {
|
||||
return errorResponse(res, 400,
|
||||
`action must be one of: ${ACTION_PREFIX_WHITELIST.join(', ')}`);
|
||||
}
|
||||
if (sinceRaw !== null && !isValidIso(sinceRaw)) {
|
||||
return errorResponse(res, 400, 'since must be ISO 8601 (e.g. 2026-08-17T00:00:00Z)');
|
||||
}
|
||||
if (untilRaw !== null && !isValidIso(untilRaw)) {
|
||||
return errorResponse(res, 400, 'until must be ISO 8601 (e.g. 2026-08-18T00:00:00Z)');
|
||||
}
|
||||
if (outcome !== null && !['success', 'failure', 'unknown'].includes(outcome)) {
|
||||
return errorResponse(res, 400, 'outcome must be one of: success, failure, unknown');
|
||||
}
|
||||
const sinceMs = sinceRaw !== null ? toEpochMs(sinceRaw) : null;
|
||||
const untilMs = untilRaw !== null ? toEpochMs(untilRaw) : null;
|
||||
if (sinceMs !== null && untilMs !== null && sinceMs > untilMs) {
|
||||
return errorResponse(res, 400, 'since must be <= until');
|
||||
}
|
||||
|
||||
// Pull the FULL store (capped at MAX_ENTRIES by audit-logger) so
|
||||
// date + outcome filters see the whole log, not the newest-N-only slice.
|
||||
// The store is bounded by design; a 1000-entry in-memory filter pass is
|
||||
// cheap (~tens of ms) and correct. Read the env-tunable MAX_ENTRIES so
|
||||
// operators who raise AUDIT_MAX_ENTRIES get correct filter coverage.
|
||||
const MAX_AUDIT_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
||||
const allEntries = await auditLogger.query({
|
||||
limit: MAX_AUDIT_ENTRIES,
|
||||
offset: 0,
|
||||
action: actionPrefix || undefined,
|
||||
});
|
||||
|
||||
let filtered = allEntries;
|
||||
if (sinceMs !== null) {
|
||||
filtered = filtered.filter((e) => {
|
||||
const t = toEpochMs(e.timestamp);
|
||||
return Number.isFinite(t) && t >= sinceMs;
|
||||
});
|
||||
}
|
||||
if (untilMs !== null) {
|
||||
filtered = filtered.filter((e) => {
|
||||
const t = toEpochMs(e.timestamp);
|
||||
return Number.isFinite(t) && t <= untilMs;
|
||||
});
|
||||
}
|
||||
if (outcome !== null) {
|
||||
filtered = filtered.filter((e) => (e.outcome || 'unknown') === outcome);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const page = filtered.slice(offset, offset + limit);
|
||||
|
||||
return success(res, {
|
||||
entries: page,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// truncated: true tells the caller the total is bounded by the
|
||||
// store's MAX_AUDIT_ENTRIES — the operator can see the whole log
|
||||
// but if more entries have been written since the last clear,
|
||||
// older rows are dropped at write-time, not at read-time.
|
||||
truncated: allEntries.length >= MAX_AUDIT_ENTRIES,
|
||||
hasMore: offset + page.length < total,
|
||||
filters: { action: actionPrefix, since: sinceRaw, until: untilRaw, outcome },
|
||||
});
|
||||
}, 'audit-logs-list'));
|
||||
|
||||
// GET /audit-logs/actions — return the distinct action prefixes present
|
||||
// in the current log, INTERSECTED with the whitelist so the dropdown
|
||||
// only offers prefixes the GET /audit-logs filter will actually accept.
|
||||
router.get('/audit-logs/actions', asyncHandler(async (req, res) => {
|
||||
const maxAudit = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
||||
const entries = await auditLogger.query({ limit: maxAudit, offset: 0 });
|
||||
const seen = new Set();
|
||||
for (const e of entries) {
|
||||
if (!e.action) continue;
|
||||
const dot = e.action.indexOf('.');
|
||||
const prefix = dot > 0 ? e.action.slice(0, dot) : e.action;
|
||||
// Only surface prefixes that are also in the whitelist — otherwise
|
||||
// the dropdown would offer a prefix that GET /audit-logs would 400.
|
||||
if (ACTION_PREFIX_WHITELIST.includes(prefix)) seen.add(prefix);
|
||||
}
|
||||
const prefixes = Array.from(seen).sort();
|
||||
return success(res, { prefixes });
|
||||
}, 'audit-logs-actions'));
|
||||
|
||||
// DELETE /audit-logs — clear the audit log. The frontend's "Clear Log"
|
||||
// button already calls DELETE /api/v1/audit-logs (status/js/audit-log.js).
|
||||
// Body must include { confirm: 'CLEAR' } as an opt-in guard against
|
||||
// accidental destructive calls.
|
||||
//
|
||||
// Forensic integrity: clear() wipes audit-log.json to []. A naive
|
||||
// "log audit.clear before clear()" leaves zero trace because clear()
|
||||
// runs after — the new entry is wiped with the rest. Fix: write the
|
||||
// audit.clear entry FIRST so it's in the buffer, then clear() the
|
||||
// store, then RE-INJECT the audit.clear entry as the single surviving
|
||||
// row. The viewer shows "1 entry: audit.clear by <user> at <ts>" — a
|
||||
// visible forensic breadcrumb that the log was just wiped.
|
||||
router.delete('/audit-logs', asyncHandler(async (req, res) => {
|
||||
const confirm = req.body?.confirm;
|
||||
if (confirm !== 'CLEAR') {
|
||||
return errorResponse(res, 400,
|
||||
'destructive op: pass { confirm: "CLEAR" } in JSON body');
|
||||
}
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const userAttrs = (req.user && req.user.id) ? {
|
||||
userId: req.user.id,
|
||||
userRole: req.user.role || null,
|
||||
userEmail: req.user.email || null,
|
||||
} : {};
|
||||
const clearEntry = {
|
||||
action: 'audit.clear',
|
||||
resource: 'audit-log.json',
|
||||
outcome: 'success',
|
||||
ip,
|
||||
details: {
|
||||
confirmedBy: req.body?.confirmedBy || 'dashboard',
|
||||
...userAttrs,
|
||||
},
|
||||
};
|
||||
// Write the clear entry FIRST so it lands at index 0 of the buffer.
|
||||
// Failure is non-fatal — the operator still wants the log cleared.
|
||||
try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
|
||||
// Now wipe the store. The just-written audit.clear entry is wiped too.
|
||||
await auditLogger.clear();
|
||||
// Re-inject the audit.clear entry so the forensic breadcrumb survives.
|
||||
// This is the difference between "log wiped, zero trace" and
|
||||
// "log wiped, viewer shows one entry: audit.clear by X at T".
|
||||
try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
|
||||
return success(res, { cleared: true });
|
||||
}, 'audit-logs-clear'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Caddy upstreams routes
|
||||
*
|
||||
* Exposes:
|
||||
* GET /api/v1/caddy/upstreams — full snapshot
|
||||
* GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker)
|
||||
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } (also via query ?muted=true)
|
||||
*
|
||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
||||
*
|
||||
* @module routes/caddy-upstreams
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
success(res, caddyUpstreamWatcher.snapshot());
|
||||
}, 'caddy-upstreams-list'));
|
||||
|
||||
router.get('/caddy/upstreams/incidents', asyncHandler(async (req, res) => {
|
||||
if (!healthChecker) {
|
||||
return success(res, { incidents: [] });
|
||||
}
|
||||
// Filter the in-memory incidents array to caddy-upstream-dead entries.
|
||||
const all = Array.isArray(healthChecker.incidents) ? healthChecker.incidents : [];
|
||||
const open = all
|
||||
.filter((i) => i && i.type === 'caddy-upstream-dead' && i.status === 'open')
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
serviceId: i.serviceId,
|
||||
type: i.type,
|
||||
message: i.message,
|
||||
severity: i.severity,
|
||||
createdAt: i.createdAt,
|
||||
lastOccurrence: i.lastOccurrence,
|
||||
occurrences: i.occurrences,
|
||||
details: i.details
|
||||
}));
|
||||
success(res, { incidents: open });
|
||||
}, 'caddy-upstreams-incidents'));
|
||||
|
||||
// POST /caddy/upstreams/mute body { host, muted }
|
||||
// POST /caddy/upstreams/:host/mute body { muted: true } OR query ?muted=true
|
||||
// Both shapes supported because the dashboard code is small and either is
|
||||
// ergonomic depending on caller.
|
||||
const handleMute = asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const host = req.params.host || req.body?.host;
|
||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
// Accept muted as boolean body field OR ?muted=true|false query OR
|
||||
// a { muted: true|false } JSON body. Default to toggling on bare POST
|
||||
// without a muted value (this is the "mute it" path).
|
||||
let muted;
|
||||
if (typeof req.body?.muted === 'boolean') muted = req.body.muted;
|
||||
else if (typeof req.query.muted === 'string') muted = req.query.muted === 'true';
|
||||
else muted = true; // POST with no body = mute
|
||||
|
||||
const result = caddyUpstreamWatcher.setMuted(host, muted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute');
|
||||
|
||||
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
|
||||
// absent or unparseable; require muted === false explicitly to unmute.
|
||||
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const { host, muted } = req.body || {};
|
||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
// Explicit boolean coercion — string 'false' should NOT mute.
|
||||
const wantMuted = muted === undefined ? true : muted === true;
|
||||
if (caddyUpstreamWatcher.upstreams && !caddyUpstreamWatcher.upstreams.has(host)) {
|
||||
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
||||
}
|
||||
const result = caddyUpstreamWatcher.setMuted(host, wantMuted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute-bare'));
|
||||
|
||||
// /:host/mute and /:host/unmute for path-style toggles
|
||||
router.post('/caddy/upstreams/:host/mute', handleMute);
|
||||
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const host = req.params.host;
|
||||
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
const result = caddyUpstreamWatcher.setMuted(host, false);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-unmute'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -162,7 +162,7 @@ module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
||||
await newContainer.start();
|
||||
} catch (startError) {
|
||||
// Clean up the failed container so it doesn't block future attempts
|
||||
log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
|
||||
log.error('docker', startError, null, { note: 'Failed to start new container', containerName });
|
||||
if (newContainer) {
|
||||
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
// DC-048 — the canonical disk-settings.json path. Shared by GET + POST.
|
||||
function getSettingsFile() {
|
||||
return path.join(platformPaths.dataDir, 'disk-settings.json');
|
||||
}
|
||||
|
||||
// GET current disk settings + actual disk usage
|
||||
router.get('/', (req, res) => {
|
||||
@@ -9,7 +15,10 @@ router.get('/', (req, res) => {
|
||||
const settings = {
|
||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000'),
|
||||
healthMaxEntries: parseInt(process.env.HEALTH_MAX_ENTRIES || '500'),
|
||||
healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '14'),
|
||||
// DC-048 — align route default to engine default (health-checker.js:34
|
||||
// reads 30 from env when unset; the route previously showed 14 as the
|
||||
// "no override" value, which silently disagreed with the engine).
|
||||
healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '30'),
|
||||
statsMaxEntries: parseInt(process.env.CONTAINER_STATS_MAX_ENTRIES || '500'),
|
||||
auditMaxEntries: parseInt(process.env.AUDIT_MAX_ENTRIES || '1000'),
|
||||
backupMaxStorageBytes: parseInt(process.env.BACKUP_MAX_STORAGE_BYTES || '0'),
|
||||
@@ -31,7 +40,7 @@ router.get('/', (req, res) => {
|
||||
} catch {}
|
||||
|
||||
// Load persisted settings
|
||||
const settingsFile = path.join(path.dirname(require('../config/paths').configFile), 'disk-settings.json');
|
||||
const settingsFile = getSettingsFile();
|
||||
let persisted = {};
|
||||
try { persisted = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
||||
|
||||
@@ -45,24 +54,37 @@ router.get('/', (req, res) => {
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { healthInterval, healthMaxEntries, healthRetentionDays, statsMaxEntries, auditMaxEntries } = req.body;
|
||||
|
||||
// DC-048 — coerce + validate EVERY numeric input before persisting.
|
||||
// Without this gate, parseInt('abc') === NaN → String(NaN) === 'NaN' →
|
||||
// process.env.HEALTH_CHECK_INTERVAL becomes 'NaN' at runtime AND the
|
||||
// persisted file gets JSON.stringify({x: NaN}) === {"x": null} which
|
||||
// the loader silently drops on next boot. Validation now rejects the
|
||||
// request with 400 BEFORE any env mutation or file write.
|
||||
const intField = (name, value) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
||||
throw new Error(`${name} must be an integer (received ${JSON.stringify(value)})`);
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const updates = {};
|
||||
|
||||
if (healthInterval !== undefined) { updates.healthCheckInterval = parseInt(healthInterval); process.env.HEALTH_CHECK_INTERVAL = String(healthInterval); }
|
||||
if (healthMaxEntries !== undefined) { updates.healthMaxEntries = parseInt(healthMaxEntries); process.env.HEALTH_MAX_ENTRIES = String(healthMaxEntries); }
|
||||
if (healthRetentionDays !== undefined) { updates.healthRetentionDays = parseInt(healthRetentionDays); process.env.HEALTH_HISTORY_RETENTION = String(healthRetentionDays); }
|
||||
if (statsMaxEntries !== undefined) { updates.statsMaxEntries = parseInt(statsMaxEntries); process.env.CONTAINER_STATS_MAX_ENTRIES = String(statsMaxEntries); }
|
||||
if (auditMaxEntries !== undefined) { updates.auditMaxEntries = parseInt(auditMaxEntries); process.env.AUDIT_MAX_ENTRIES = String(auditMaxEntries); }
|
||||
|
||||
if (healthInterval !== undefined) { const n = intField('healthInterval', healthInterval); updates.healthCheckInterval = n; process.env.HEALTH_CHECK_INTERVAL = String(n); }
|
||||
if (healthMaxEntries !== undefined) { const n = intField('healthMaxEntries', healthMaxEntries); updates.healthMaxEntries = n; process.env.HEALTH_MAX_ENTRIES = String(n); }
|
||||
if (healthRetentionDays !== undefined) { const n = intField('healthRetentionDays', healthRetentionDays); updates.healthRetentionDays = n; process.env.HEALTH_HISTORY_RETENTION = String(n); }
|
||||
if (statsMaxEntries !== undefined) { const n = intField('statsMaxEntries', statsMaxEntries); updates.statsMaxEntries = n; process.env.CONTAINER_STATS_MAX_ENTRIES = String(n); }
|
||||
if (auditMaxEntries !== undefined) { const n = intField('auditMaxEntries', auditMaxEntries); updates.auditMaxEntries = n; process.env.AUDIT_MAX_ENTRIES = String(n); }
|
||||
|
||||
// Persist to file
|
||||
const paths = require('../config/paths');
|
||||
const settingsFile = path.join(path.dirname(paths.configFile), 'disk-settings.json');
|
||||
const settingsFile = getSettingsFile();
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {}
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ ...existing, ...updates }, null, 2));
|
||||
|
||||
|
||||
res.json({ success: true, updated: updates, message: 'Settings saved. Some changes apply on next container restart.' });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
res.status(e.statusCode || 400).json({ success: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ module.exports = function({
|
||||
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS record creation error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'Universal DNS record creation error' });
|
||||
errorResponse(res, safeErrorMessage(error), 500);
|
||||
}
|
||||
}, 'dns-universal-create'));
|
||||
@@ -136,7 +136,7 @@ module.exports = function({
|
||||
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS record deletion error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'Universal DNS record deletion error' });
|
||||
errorResponse(res, safeErrorMessage(error), 500);
|
||||
}
|
||||
}, 'dns-universal-delete'));
|
||||
@@ -167,7 +167,7 @@ module.exports = function({
|
||||
throw new NotFoundError('No records found for domain');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'Universal DNS resolve error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'Universal DNS resolve error' });
|
||||
errorResponse(res, safeErrorMessage(error), error.statusCode || 500);
|
||||
}
|
||||
}, 'dns-universal-resolve'));
|
||||
@@ -283,7 +283,7 @@ module.exports = function({
|
||||
// Error handled by middleware
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS record creation error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS record creation error' });
|
||||
errorResponse(res, safeErrorMessage(error), 500, { details: error.cause?.code || 'fetch failed' });
|
||||
}
|
||||
}, 'dns-create-record'));
|
||||
@@ -328,7 +328,7 @@ module.exports = function({
|
||||
// Error handled by middleware
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS resolve error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS resolve error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-resolve'));
|
||||
@@ -465,7 +465,7 @@ module.exports = function({
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS logs proxy error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS logs proxy error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-logs'));
|
||||
@@ -723,7 +723,7 @@ module.exports = function({
|
||||
// Error handled by middleware
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS update check error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS update check error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-check-update'));
|
||||
@@ -791,7 +791,7 @@ module.exports = function({
|
||||
manualUpdateRequired: true
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS update error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS update error' });
|
||||
// Error handled by middleware
|
||||
}
|
||||
}, 'dns-update'));
|
||||
|
||||
@@ -1,9 +1,80 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||
const { success } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
// The unified error logger writes entries separated by a long horizontal-rule
|
||||
// line made of U+2500 BOX DRAWINGS LIGHT HORIZONTAL (verified 2026-08-18
|
||||
// against /opt/dashcaddy/dashcaddy-api/data/error.log on DNS2 — the previous
|
||||
// implementation split on '='.repeat(80), which returned ONE block and
|
||||
// produced ZERO entries for the modal). Anything else got dropped silently.
|
||||
const ENTRY_SEPARATOR_RE = /\n\u2500{20,}\n?/;
|
||||
const ENTRY_HEADER_RE = /^\[([^\]]+)\]\s+\[([A-Z]+)\]\s+(.*?):\s*(.*)$/;
|
||||
|
||||
const MAX_TAIL = 500;
|
||||
const MAX_TAIL_BYTES = 2 * 1024 * 1024; // never read more than 2 MiB from disk
|
||||
|
||||
/**
|
||||
* Parse the unified error-log format into structured entries.
|
||||
* Each entry:
|
||||
* [2026-08-16T23:13:14.123Z] [ERR] ctx: message
|
||||
* <stack trace lines, if any>
|
||||
* request: ... (optional)
|
||||
* context: {...} (optional)
|
||||
* ────────────── (separator)
|
||||
* @param {string} text
|
||||
* @returns {Array<{timestamp:string,level:string,context:string,message:string,details:string|null}>}
|
||||
*/
|
||||
function parseEntries(text) {
|
||||
if (!text) return [];
|
||||
const blocks = text.split(ENTRY_SEPARATOR_RE);
|
||||
const entries = [];
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.replace(/^\n+|\n+$/g, '');
|
||||
if (!trimmed) continue;
|
||||
const firstLineEnd = trimmed.indexOf('\n');
|
||||
const firstLine = firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd);
|
||||
const rest = firstLineEnd === -1 ? '' : trimmed.slice(firstLineEnd + 1);
|
||||
const m = firstLine.match(ENTRY_HEADER_RE);
|
||||
if (!m) continue;
|
||||
entries.push({
|
||||
timestamp: m[1],
|
||||
level: m[2],
|
||||
context: m[3],
|
||||
message: m[4],
|
||||
details: rest ? rest.replace(/\n+$/g, '') : null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the last N bytes of a UTF-8 file safely (so the 4 MiB log doesn't
|
||||
* blow up memory or block the event loop). Splits on the first complete
|
||||
* line boundary after the cut.
|
||||
*/
|
||||
async function readTailBytes(filePath, byteLimit) {
|
||||
const fh = await fsp.open(filePath, 'r');
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
const start = Math.max(0, stat.size - byteLimit);
|
||||
const length = stat.size - start;
|
||||
const buf = Buffer.alloc(length);
|
||||
await fh.read(buf, 0, length, start);
|
||||
let text = buf.toString('utf8');
|
||||
// If we cut into the middle of a UTF-8 sequence, drop the partial char
|
||||
const partialLead = text.match(/[\uD800-\uDBFF]$/);
|
||||
if (partialLead) text = text.slice(0, -1);
|
||||
// Drop a half first line so we never start mid-entry
|
||||
const nl = text.indexOf('\n');
|
||||
if (start > 0 && nl !== -1) text = text.slice(nl + 1);
|
||||
return { text, totalSize: stat.size, truncated: start > 0 };
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error logs routes factory
|
||||
@@ -17,38 +88,41 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Get error logs
|
||||
// GET /api/v1/error-logs?tail=100&level=ERR
|
||||
// - tail: cap on returned entries (default 100, max 500)
|
||||
// - level: filter by level (ERR/WARN/INFO/DBG) — case-insensitive
|
||||
router.get('/error-logs', asyncHandler(async (req, res) => {
|
||||
if (!await exists(ERROR_LOG_FILE)) {
|
||||
return success(res, { logs: [] });
|
||||
return success(res, { logs: [], totalSize: 0, truncated: false });
|
||||
}
|
||||
|
||||
const logContent = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
|
||||
let tailRaw = parseInt(req.query.tail, 10);
|
||||
if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100;
|
||||
const tail = Math.min(tailRaw, MAX_TAIL);
|
||||
|
||||
const logs = logEntries.map(entry => {
|
||||
const lines = entry.trim().split('\n');
|
||||
const firstLine = lines[0] || '';
|
||||
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
|
||||
const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null;
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
timestamp: match[1],
|
||||
context: match[2],
|
||||
error: match[3]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES);
|
||||
let logs = parseEntries(text);
|
||||
|
||||
success(res, { logs: logs.slice(-50).reverse() });
|
||||
if (levelFilter) {
|
||||
logs = logs.filter(e => e.level === levelFilter);
|
||||
}
|
||||
|
||||
// Newest first; bounded by `tail`
|
||||
logs = logs.slice(-tail).reverse();
|
||||
|
||||
success(res, { logs, totalSize, truncated, returned: logs.length });
|
||||
}, 'error-logs-get'));
|
||||
|
||||
// Clear error logs
|
||||
router.delete('/error-logs', asyncHandler(async (req, res) => {
|
||||
if (await exists(ERROR_LOG_FILE)) {
|
||||
await fsp.writeFile(ERROR_LOG_FILE, '');
|
||||
if (!await exists(ERROR_LOG_FILE)) {
|
||||
return success(res, { message: 'Error logs cleared', cleared: 0 });
|
||||
}
|
||||
success(res, { message: 'Error logs cleared' });
|
||||
const before = await fsp.stat(ERROR_LOG_FILE).then(s => s.size).catch(() => 0);
|
||||
await fsp.writeFile(ERROR_LOG_FILE, '');
|
||||
success(res, { message: 'Error logs cleared', clearedBytes: before });
|
||||
}, 'error-logs-clear'));
|
||||
|
||||
// Audit log
|
||||
@@ -56,7 +130,6 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
|
||||
const paginationParams = parsePaginationParams(req.query);
|
||||
const action = req.query.action || '';
|
||||
if (paginationParams) {
|
||||
// When paginating, fetch all matching entries and let pagination slice
|
||||
const entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action });
|
||||
const result = paginate(entries, paginationParams);
|
||||
success(res, { entries: result.data, pagination: result.pagination });
|
||||
@@ -75,3 +148,9 @@ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) {
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// Exported for unit testing
|
||||
module.exports.parseEntries = parseEntries;
|
||||
module.exports.readTailBytes = readTailBytes;
|
||||
module.exports.MAX_TAIL = MAX_TAIL;
|
||||
module.exports.MAX_TAIL_BYTES = MAX_TAIL_BYTES;
|
||||
@@ -165,7 +165,7 @@ async function handleExec(ws, containerId, log, auth) {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
log.error('exec', 'Failed to start exec session', { containerId, error: err.message });
|
||||
log.error('exec', err, null, { note: 'Failed to start exec session', containerId });
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
ws.close();
|
||||
|
||||
@@ -8,19 +8,17 @@ const i18n = require('../src/utilities/i18n');
|
||||
module.exports = function() {
|
||||
const router = express.Router();
|
||||
|
||||
// Language display names and RTL metadata for the full supported set.
|
||||
const NAMES = {en: "English",es: "Espa\u00f1ol",fr: "Fran\u00e7ais",de: "Deutsch",ar: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629",bn: "\u09ac\u09be\u0982\u09b2\u09be",cs: "\u010ce\u0161tina",da: "Dansk",el: "\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac",fa: "\u0641\u0627\u0631\u0633\u06cc",fi: "Suomi",hi: "\u0939\u093f\u0928\u094d\u0926\u0940",hu: "Magyar",id: "Bahasa Indonesia",it: "Italiano",ja: "\u65e5\u672c\u8a9e",ko: "\ud55c\uad6d\uc5b4",ms: "Bahasa Melayu",nl: "Nederlands",no: "Norsk",pl: "Polski",pt: "Portugu\u00eas",ro: "Rom\u00e2n\u0103",ru: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",sv: "Svenska",th: "\u0e44\u0e17\u0e22",tr: "T\u00fcrk\u00e7e",uk: "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430",ur: "\u0627\u0631\u062f\u0648",vi: "Ti\u1ebfng Vi\u1ec7t",zh: "\u4e2d\u6587"};
|
||||
const RTL = new Set(['ar', 'fa', 'ur']);
|
||||
|
||||
// GET /api/v1/i18n/languages — list supported languages
|
||||
router.get('/i18n/languages', (req, res) => {
|
||||
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',
|
||||
name: NAMES[code] || code,
|
||||
rtl: RTL.has(code),
|
||||
})),
|
||||
default: i18n.DEFAULT_LANGUAGE,
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
||||
|
||||
ok(res, response);
|
||||
} catch (error) {
|
||||
log.error('recipe', 'Recipe deployment failed', { recipeId, error: error.message });
|
||||
log.error('recipe', error, null, { note: 'Recipe deployment failed', recipeId });
|
||||
|
||||
// Cleanup: remove partially deployed containers
|
||||
for (const deployed of deployedComponents) {
|
||||
|
||||
@@ -421,7 +421,7 @@ module.exports = function({
|
||||
resyncHealthChecker?.().catch(() => {});
|
||||
success(res, { message: `Service "${name}" added to dashboard` });
|
||||
} catch (error) {
|
||||
log.error('deploy', 'Error adding service', { error: error.message });
|
||||
log.error('deploy', error, null, { note: 'Error adding service' });
|
||||
if (error.message.includes('already exists')) {
|
||||
errorResponse(res, safeErrorMessage(error), 409);
|
||||
} else {
|
||||
|
||||
@@ -46,7 +46,7 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log.error('caddy', 'Caddy reload failed', { error: errorText });
|
||||
log.error('caddy', new Error(`Caddy reload failed: ${errorText.slice(0, 500)}`));
|
||||
throw new Error('Caddy reload failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ module.exports = function({ asyncHandler, log }) {
|
||||
themes[slug] = data;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('themes', 'Failed to read themes', { error: e.message });
|
||||
log.error('themes', e, null, { note: 'Failed to read themes' });
|
||||
}
|
||||
return themes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Version route — exposes the running application version and runtime metadata.
|
||||
*
|
||||
* The version comes from package.json at module load time so the response
|
||||
* always matches the running code. Extracted from src/app.js into its own
|
||||
* module so production wiring and tests share the same code path.
|
||||
*/
|
||||
const express = require('express');
|
||||
|
||||
let appVersion = '0.0.0';
|
||||
let appName = 'dashcaddy-api';
|
||||
try {
|
||||
const pkg = require('../package.json');
|
||||
if (pkg && pkg.version) appVersion = pkg.version;
|
||||
if (pkg && pkg.name) appName = pkg.name;
|
||||
} catch (_) {
|
||||
/* package.json unreadable — keep fallback */
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
return appVersion;
|
||||
}
|
||||
|
||||
function getName() {
|
||||
return appName;
|
||||
}
|
||||
|
||||
function buildRouter() {
|
||||
const router = express.Router();
|
||||
router.get('/version', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
name: appName,
|
||||
version: appVersion,
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
uptime: process.uptime(),
|
||||
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
|
||||
});
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
// Allow direct use as a factory (no-op for version since it has no deps)
|
||||
// or destructuring of { buildRouter, getVersion, getName }.
|
||||
module.exports = module.exports.default || module.exports;
|
||||
module.exports.buildRouter = buildRouter;
|
||||
module.exports.getVersion = getVersion;
|
||||
module.exports.getName = getName;
|
||||
module.exports.default = function factory() { return buildRouter(); };
|
||||
@@ -106,6 +106,7 @@ const path = require('path');
|
||||
const { generateCodes, loadSecret } = require('../license-keygen');
|
||||
const platformPaths = require('../platform-paths');
|
||||
const catalog = require('../src/billing/catalog');
|
||||
const invoice = require('../src/billing/invoice');
|
||||
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');
|
||||
|
||||
// ── Configuration (env-driven) ──────────────────────────────────────────────
|
||||
@@ -244,33 +245,69 @@ function eventSeen(eventId) {
|
||||
// ── Email delivery ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send the license key email. If SMTP is configured, real send via
|
||||
* Send the license key + invoice email. If SMTP is configured, real send via
|
||||
* nodemailer; if not, log the full email body to stdout so the operator
|
||||
* can deliver manually in dev/test environments.
|
||||
*
|
||||
* The email is multipart/alternative (text + HTML, matching the same
|
||||
* branded content) with a branded PDF invoice attached. Rendered by
|
||||
* src/billing/invoice.js — see that module for the security/escape rules.
|
||||
*
|
||||
* Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
|
||||
*/
|
||||
async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
const subject = `Your DashCaddy Pro license (${durationDays} days)`;
|
||||
const text = [
|
||||
'Thank you for purchasing DashCaddy Pro.',
|
||||
'',
|
||||
`Your license key is valid for ${durationDays} days:`,
|
||||
'',
|
||||
` ${code}`,
|
||||
'',
|
||||
'To install on your DashCaddy host:',
|
||||
' 1. Open https://<your-host>/admin/license',
|
||||
' 2. Paste the key into the "Activate license" field',
|
||||
' 3. Submit — Pro features unlock immediately.',
|
||||
'',
|
||||
'The same key is also revealed on your purchase success page; keep it safe.',
|
||||
'',
|
||||
'Need help? Reply to this email and we will assist.',
|
||||
'',
|
||||
`Reference: ${eventId}`,
|
||||
`Product: ${productId}`,
|
||||
].join('\n');
|
||||
async function deliverCode({ to, code, durationDays, eventId, productId, customerName, sessionId, amountCents, currency, supportUrl, issuedAt }) {
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) {
|
||||
// Should never happen — catalog resolution happens upstream. Defensive
|
||||
// throw so the operator notices misconfiguration instead of silently
|
||||
// sending a half-blank invoice.
|
||||
throw new Error(`deliverCode: unknown productId ${productId}`);
|
||||
}
|
||||
|
||||
const invoiceInput = {
|
||||
email: to,
|
||||
customerName: customerName || '',
|
||||
code,
|
||||
durationDays,
|
||||
productLabel: product.label,
|
||||
productId: product.id,
|
||||
amountCents: amountCents != null ? amountCents : product.amountCents,
|
||||
currency: currency || 'USD',
|
||||
eventId,
|
||||
sessionId: sessionId || '',
|
||||
supportUrl: supportUrl || 'https://dashcaddy.net',
|
||||
issuedAt: issuedAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { subject, html } = invoice.renderLicenseEmailHtml(invoiceInput);
|
||||
const text = invoice.renderLicenseEmailText(invoiceInput);
|
||||
|
||||
// PDF generation can throw on poison-pill inputs that survive sanitization
|
||||
// (e.g. lone surrogates in customer name that PDFKit's WinAnsi encoder
|
||||
// rejects, or malformed `issuedAt` after the bridge passes a bad value).
|
||||
// We try the PDF and degrade gracefully: send text+HTML WITHOUT the PDF
|
||||
// attachment so the customer still gets the license + invoice link rather
|
||||
// than nothing. The fulfillment record still marks `delivered` — the
|
||||
// license was persisted upstream, so lookup always works regardless.
|
||||
let pdfBuffer = null;
|
||||
let pdfError = null;
|
||||
try {
|
||||
pdfBuffer = await invoice.renderInvoicePdf(invoiceInput);
|
||||
} catch (err) {
|
||||
pdfError = err;
|
||||
log('warn', 'pdf-render-failed-degrading-to-text-only', {
|
||||
eventId, sessionId, error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize the PDF filename — event id has Stripe's prefix and underscores
|
||||
// which are safe, but we constrain the charset anyway for attachment
|
||||
// parsers that may be picky.
|
||||
const safeInvoiceNumber = invoice.sanitizeFilenameSegment(
|
||||
invoice.generateInvoiceNumber(eventId),
|
||||
'invoice'
|
||||
);
|
||||
const attachmentFilename = `DashCaddy-Pro-Invoice-${safeInvoiceNumber}.pdf`;
|
||||
|
||||
const smtp = _smtpConfig();
|
||||
if (!smtp.host || !smtp.from) {
|
||||
@@ -281,7 +318,10 @@ async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
// operator seeing the bridge logs IS the documented delivery path
|
||||
// when SMTP is unconfigured. In production, the bridge refuses to
|
||||
// boot without SMTP configured (see checkFatalConfig).
|
||||
log('info', 'smtp-not-configured, falling back to dev-console delivery', { to, durationDays, code });
|
||||
log('info', 'smtp-not-configured, falling back to dev-console delivery', {
|
||||
to, durationDays, code, invoiceNumber: safeInvoiceNumber,
|
||||
pdfBytes: pdfBuffer ? pdfBuffer.length : null, pdfError: pdfError && pdfError.message,
|
||||
});
|
||||
return { delivered: true, via: 'dev-console' };
|
||||
}
|
||||
|
||||
@@ -294,7 +334,24 @@ async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
|
||||
tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
|
||||
});
|
||||
await transporter.sendMail({ from: smtp.from, to, subject, text });
|
||||
const mailArgs = {
|
||||
from: smtp.from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
};
|
||||
if (pdfBuffer) {
|
||||
mailArgs.attachments = [
|
||||
{
|
||||
filename: attachmentFilename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
encoding: 'base64',
|
||||
},
|
||||
];
|
||||
}
|
||||
await transporter.sendMail(mailArgs);
|
||||
return { delivered: true, via: 'smtp' };
|
||||
}
|
||||
|
||||
@@ -464,6 +521,57 @@ async function fulfillCheckout({ id, session }) {
|
||||
const sessionId = session.id || '';
|
||||
if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };
|
||||
|
||||
// Stripe sends the customer's name on `customer_details.name` for hosted
|
||||
// Checkout (sometimes blank — they may have entered only an email). We
|
||||
// pass it through to the invoice renderer for the "Hi <first name>" greeting
|
||||
// and the bill-to block.
|
||||
const customerName = (session.customer_details && session.customer_details.name) || '';
|
||||
|
||||
// Amount comes from the session's line_items (Stripe Checkout totals).
|
||||
// Older sessions may not have line_items expanded — fall back to the
|
||||
// session amount_total, then to the catalog amount so the invoice is
|
||||
// never blank. The invoice is a financial document — we ALWAYS render
|
||||
// the catalog's canonical amount when Stripe doesn't tell us a different
|
||||
// one, because the catalog is the single source of truth for DashCaddy's
|
||||
// pricing. This prevents Stripe Checkout config drift (e.g. a test
|
||||
// coupon, a multi-seat plan we don't support) from producing invoices
|
||||
// that don't match the user's actual entitlement.
|
||||
let amountCents = null;
|
||||
let currency = (session.currency || 'USD').toString().toUpperCase();
|
||||
const lineItems = session.line_items && session.line_items.data;
|
||||
if (Array.isArray(lineItems) && lineItems.length > 0) {
|
||||
// Sum ALL line items, not just lineItems[0]. The previous version
|
||||
// silently dropped quantity > 1 or multi-item carts, producing
|
||||
// invoices whose total didn't match the Stripe charge. session.amount_total
|
||||
// does this automatically too, but reading line items ourselves lets us
|
||||
// log a warning when Stripe's amount_total disagrees with the line-item
|
||||
// sum (indicative of a Stripe-side bug or tampering).
|
||||
const sumFromLineItems = lineItems.reduce((acc, item) => {
|
||||
if (item && item.amount_total != null) return acc + item.amount_total;
|
||||
if (item && item.price && item.price.unit_amount != null) return acc + item.price.unit_amount;
|
||||
return acc;
|
||||
}, 0);
|
||||
if (sumFromLineItems > 0) amountCents = sumFromLineItems;
|
||||
if (lineItems[0] && lineItems[0].currency) currency = lineItems[0].currency.toUpperCase();
|
||||
}
|
||||
if (amountCents == null && session.amount_total != null) {
|
||||
amountCents = session.amount_total;
|
||||
}
|
||||
// Final fallback: catalog's canonical price for this product. This is
|
||||
// the single source of truth — if Stripe sends 0 or NaN, we render the
|
||||
// catalog price rather than a $0.00 invoice for a real charge.
|
||||
if (amountCents == null || !Number.isFinite(amountCents) || amountCents <= 0) {
|
||||
log('warn', 'amount-fell-back-to-catalog', {
|
||||
eventId: id, sessionId, stripeAmountCents: amountCents, catalogAmountCents: product.amountCents,
|
||||
});
|
||||
amountCents = product.amountCents;
|
||||
}
|
||||
// Currency must always be a 3-letter ISO code; sanitize otherwise.
|
||||
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||
log('warn', 'invalid-currency-from-stripe', { eventId: id, sessionId, stripeCurrency: currency });
|
||||
currency = 'USD';
|
||||
}
|
||||
|
||||
const claim = await fulfillmentStore.claim({
|
||||
eventId: id, sessionId, productId: product.id, durationDays, email,
|
||||
});
|
||||
@@ -479,10 +587,49 @@ async function fulfillCheckout({ id, session }) {
|
||||
if (deliveryClaim.busy) {
|
||||
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
|
||||
}
|
||||
// Layer-2 delivery idempotency: if the claim was NOT successful AND the
|
||||
// record is already `delivered`, an earlier event (or this same event via
|
||||
// layer-1) already produced an invoice email. Stripe may legitimately send
|
||||
// `checkout.session.completed` AND `checkout.session.async_payment_succeeded`
|
||||
// for the same Checkout Session (delayed-payment methods). Without this
|
||||
// guard the customer receives TWO invoice emails with TWO different
|
||||
// invoice numbers for one charge. Ack 200 so Stripe stops retrying.
|
||||
if (deliveryClaim.claimed === false
|
||||
&& deliveryClaim.record
|
||||
&& deliveryClaim.record.status === 'delivered') {
|
||||
log('info', 'delivery-already-completed', {
|
||||
eventId: id, sessionId, ownerEventId: deliveryClaim.record.eventId,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
delivered: true,
|
||||
deduplicated: true,
|
||||
codeId: deliveryClaim.record.codeId,
|
||||
productId: deliveryClaim.record.productId,
|
||||
durationDays: deliveryClaim.record.durationDays,
|
||||
deliveredVia: deliveryClaim.record.deliveredVia || 'smtp',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id });
|
||||
delivery = await deliverCode({
|
||||
to: email,
|
||||
code,
|
||||
durationDays,
|
||||
eventId: id,
|
||||
productId: product.id,
|
||||
customerName,
|
||||
sessionId,
|
||||
amountCents,
|
||||
currency,
|
||||
// Pin issuedAt to the ORIGINAL claim's createdAt so a retry days later
|
||||
// renders the same "Issued" date. Falls back to now() for first-time.
|
||||
issuedAt: claim.record && claim.record.createdAt,
|
||||
supportUrl: process.env.DASHCADDY_SUPPORT_URL || 'https://dashcaddy.net',
|
||||
});
|
||||
} catch (err) {
|
||||
log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
|
||||
await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
|
||||
|
||||
+25
-26
@@ -68,30 +68,29 @@ process.on('uncaughtException', (error) => {
|
||||
attachExecWS(server, log, authManager);
|
||||
log.info('server', 'WebSocket exec handler attached (auth enforced)');
|
||||
|
||||
// DC-076: Attach dashboard WebSocket for real-time updates
|
||||
// DC-076: Attach dashboard WebSocket for real-time updates.
|
||||
// createApp() returns the live manager instances — use those instead
|
||||
// of re-requiring the modules (which yields singletons for some
|
||||
// managers and raw classes / namespace objects for others; calling
|
||||
// .on() on a class threw on every boot and silently killed the WS).
|
||||
try {
|
||||
const { ctx } = app.locals;
|
||||
const createDashboardWS = require('./src/websocket/dashboard-ws');
|
||||
const resourceMonitor = require('./src/managers/resource-monitor');
|
||||
const healthChecker = require('./src/monitoring/health-checker');
|
||||
const updateManager = require('./src/managers/update-manager');
|
||||
const dependencyManager = require('./src/managers/dependency-manager');
|
||||
const autoRestartManager = require('./src/managers/auto-restart-manager');
|
||||
const configDriftDetector = require('./src/managers/config-drift-detector');
|
||||
const sslMonitor = require('./src/monitoring/ssl-monitor');
|
||||
|
||||
createDashboardWS(server, {
|
||||
resourceMonitor,
|
||||
healthChecker,
|
||||
updateManager,
|
||||
dependencyManager,
|
||||
autoRestartManager,
|
||||
driftDetector: configDriftDetector,
|
||||
sslMonitor,
|
||||
resourceMonitor: ctx.resourceMonitor,
|
||||
healthChecker: ctx.healthChecker,
|
||||
updateManager: ctx.updateManager,
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
driftDetector: ctx.driftDetector,
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker,
|
||||
log,
|
||||
});
|
||||
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
|
||||
} catch (err) {
|
||||
log.error('server', 'Dashboard WebSocket failed to attach', { error: err.message });
|
||||
log.error('server', err, null, { feature: 'dashboard-ws' });
|
||||
}
|
||||
|
||||
// Start feature modules
|
||||
@@ -136,7 +135,7 @@ process.on('uncaughtException', (error) => {
|
||||
workflowEngine = new WorkflowEngine(workflowCtx);
|
||||
log.info('server', 'Workflow engine initialized');
|
||||
} catch (err) {
|
||||
log.error('server', 'Workflow engine failed to initialize', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Workflow engine failed to initialize' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +144,7 @@ process.on('uncaughtException', (error) => {
|
||||
// Clean up stale port locks
|
||||
portLockManager.cleanupStaleLocks()
|
||||
.then(() => log.info('server', 'Port lock cleanup completed'))
|
||||
.catch(err => log.error('server', 'Port lock cleanup failed', { error: err.message }));
|
||||
.catch(err => log.error('server', err, null, { note: 'Port lock cleanup failed' }));
|
||||
|
||||
// Resource monitoring
|
||||
try {
|
||||
@@ -156,7 +155,7 @@ process.on('uncaughtException', (error) => {
|
||||
}
|
||||
log.info('server', 'Resource monitoring started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Resource monitoring failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Resource monitoring failed to start' });
|
||||
}
|
||||
|
||||
// Backup manager
|
||||
@@ -164,7 +163,7 @@ process.on('uncaughtException', (error) => {
|
||||
backupManager.start();
|
||||
log.info('server', 'Backup manager started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Backup manager failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Backup manager failed to start' });
|
||||
}
|
||||
|
||||
// Security event workers (Caddy access log, fail2ban, shared_bans)
|
||||
@@ -175,7 +174,7 @@ process.on('uncaughtException', (error) => {
|
||||
startSecurityWorkers({ log });
|
||||
log.info('server', 'Security event workers started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Security event workers failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Security event workers failed to start' });
|
||||
}
|
||||
|
||||
// Connect workflow engine to update manager for pre-update events
|
||||
@@ -206,7 +205,7 @@ process.on('uncaughtException', (error) => {
|
||||
healthChecker.start();
|
||||
log.info('server', 'Health checker started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Health checker failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Health checker failed to start' });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -215,7 +214,7 @@ process.on('uncaughtException', (error) => {
|
||||
updateManager.start();
|
||||
log.info('server', 'Update manager started');
|
||||
} catch (err) {
|
||||
log.error('server', 'Update manager failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Update manager failed to start' });
|
||||
}
|
||||
|
||||
// Self-updater
|
||||
@@ -234,7 +233,7 @@ process.on('uncaughtException', (error) => {
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
log.error('server', 'Self-updater failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Self-updater failed to start' });
|
||||
}
|
||||
|
||||
// Docker maintenance (optional)
|
||||
@@ -257,7 +256,7 @@ process.on('uncaughtException', (error) => {
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('server', 'Docker maintenance failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Docker maintenance failed to start' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +270,7 @@ process.on('uncaughtException', (error) => {
|
||||
log.info('digest', `Daily digest generated for ${date}`);
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('server', 'Log digest failed to start', { error: err.message });
|
||||
log.error('server', err, null, { note: 'Log digest failed to start' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-23
@@ -19,6 +19,11 @@ const { asyncHandler } = require('./utils/async-handler');
|
||||
// Managers and utilities
|
||||
const StateManager = require('./managers/state-manager');
|
||||
const platformPaths = require('../platform-paths');
|
||||
// DC-048 — rehydrate process.env from disk-settings.json BEFORE any engine
|
||||
// module reads env at module-load time. Must run before health-checker,
|
||||
// audit-logger, and the backups route module (backups.js reads
|
||||
// BACKUP_MAX_STORAGE_BYTES at module load too).
|
||||
require('./config/disk-settings-loader')();
|
||||
const { LicenseManager } = require('./managers/license-manager');
|
||||
const credentialManager = require('./managers/credential-manager');
|
||||
const authManager = require('./managers/auth-manager');
|
||||
@@ -97,7 +102,9 @@ const securityRoutes = require('../routes/security');
|
||||
const diskSettingsRoutes = require('../routes/disk-settings');
|
||||
const aiIntentRoutes = require('../routes/ai-intent');
|
||||
const logInsightsRoutes = require('../routes/log-insights');
|
||||
const auditLogRoutes = require('../routes/audit-log');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const caddyUpstreamRoutes = require('../routes/caddy-upstreams');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
@@ -107,6 +114,7 @@ const { AutoRestartManager } = require('./managers/auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
|
||||
const SSLMonitor = require('./monitoring/ssl-monitor');
|
||||
const { DiskSpaceMonitor } = require('./monitoring/disk-space-monitor');
|
||||
const caddyUpstreamWatcher = require('./monitoring/caddy-upstream-watcher');
|
||||
const DNSPropagationChecker = require('./dns/dns-propagation');
|
||||
|
||||
// Constants
|
||||
@@ -318,7 +326,7 @@ async function createApp() {
|
||||
const { writeJsonFile } = require('./utilities/fs-helpers');
|
||||
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
|
||||
} catch (e) {
|
||||
log.error('config', 'Could not save TOTP config', { error: e.message });
|
||||
log.error('config', e, null, { note: 'Could not save TOTP config' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +445,7 @@ async function createApp() {
|
||||
ctx.workflowEngine = workflowEngine;
|
||||
log.info('app', 'Workflow engine initialized');
|
||||
} catch (err) {
|
||||
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
|
||||
log.error('app', err, null, { note: 'Failed to initialize workflow engine' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +483,15 @@ async function createApp() {
|
||||
diskSpaceMonitor.start(600000); // 10 min
|
||||
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
|
||||
|
||||
// Initialize caddy upstream watcher — independent probes of every
|
||||
// reverse_proxy directive in /etc/caddy/sites/, emits 'dead' incidents
|
||||
// after 5min of consecutive failures (so a single blip doesn't page).
|
||||
caddyUpstreamWatcher.log = log;
|
||||
caddyUpstreamWatcher.healthChecker = healthChecker;
|
||||
caddyUpstreamWatcher.start();
|
||||
ctx.caddyUpstreamWatcher = caddyUpstreamWatcher;
|
||||
log.info('app', 'Caddy upstream watcher initialized');
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
||||
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
||||
@@ -484,37 +501,29 @@ async function createApp() {
|
||||
const apiRouter = express.Router();
|
||||
|
||||
// Version endpoint — public, no auth required
|
||||
// Reads version from package.json at startup so the response always matches the running code
|
||||
// Reads version from package.json at startup so the response always matches the running code.
|
||||
// The handler is implemented in routes/version.js but is registered inline here so
|
||||
// public-routes-drift.test.js (which walks apiRouter.stack directly) can see it.
|
||||
let appVersion = '0.0.0';
|
||||
let appName = 'dashcaddy-api';
|
||||
try {
|
||||
const pkg = require('../package.json');
|
||||
appVersion = pkg.version || appVersion;
|
||||
appName = pkg.name || appName;
|
||||
} catch { /* package.json unreadable — keep fallback */ }
|
||||
apiRouter.get('/version', (req, res) => {
|
||||
ok(res, {
|
||||
name: appName,
|
||||
version: appVersion,
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
uptime: process.uptime(),
|
||||
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
|
||||
});
|
||||
});
|
||||
const versionRoute = require('../routes/version');
|
||||
appVersion = versionRoute.getVersion();
|
||||
appName = versionRoute.getName();
|
||||
// Pre-build the version router once at startup and reuse it.
|
||||
const versionRouter = versionRoute.buildRouter();
|
||||
apiRouter.use(versionRouter);
|
||||
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
|
||||
|
||||
// Wire up notification listeners for resourceMonitor and backupManager
|
||||
if (ctx.notification && ctx.resourceMonitor) {
|
||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
||||
ctx.notification.sendAlert(alertData).catch(err => {
|
||||
log.error('notification', 'Failed to send alert', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send alert' });
|
||||
});
|
||||
});
|
||||
ctx.resourceMonitor.on('auto-restart', (data) => {
|
||||
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
|
||||
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send auto-restart notification' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -522,12 +531,12 @@ async function createApp() {
|
||||
if (ctx.notification && ctx.backupManager) {
|
||||
ctx.backupManager.on('backup-complete', (data) => {
|
||||
ctx.notification.send('backup-complete', data).catch(err => {
|
||||
log.error('notification', 'Failed to send backup-complete', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send backup-complete' });
|
||||
});
|
||||
});
|
||||
ctx.backupManager.on('backup-failed', (data) => {
|
||||
ctx.notification.send('backup-failed', data).catch(err => {
|
||||
log.error('notification', 'Failed to send backup-failed', { error: err.message });
|
||||
log.error('notification', err, null, { note: 'Failed to send backup-failed' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -773,6 +782,15 @@ async function createApp() {
|
||||
})()
|
||||
}));
|
||||
|
||||
// DC-050 — Audit log viewer route. The frontend at status/js/audit-log.js
|
||||
// has been calling /api/v1/audit-logs since 2026-05-27; before this route
|
||||
// existed the dashboard silently 404'd. The audit-logger module already
|
||||
// exposes query() and clear() — this route just gives them an HTTP shape.
|
||||
apiRouter.use(auditLogRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
auditLogger: ctx.auditLogger,
|
||||
}));
|
||||
|
||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
@@ -797,6 +815,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use(caddyUpstreamRoutes({
|
||||
caddyUpstreamWatcher: ctx.caddyUpstreamWatcher,
|
||||
healthChecker: ctx.healthChecker,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
}));
|
||||
apiRouter.use('/disk', diskSpaceRoutes({
|
||||
diskSpaceMonitor: ctx.diskSpaceMonitor,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
@@ -1105,6 +1128,10 @@ async function createApp() {
|
||||
app.use('/api', notFoundHandler);
|
||||
app.use(errorMiddleware);
|
||||
|
||||
// Expose ctx on the app for entry points (server.js dashboard-WS wiring)
|
||||
// without changing the returned shape for existing callers/tests.
|
||||
app.locals.ctx = ctx;
|
||||
|
||||
return { app, log, config: config.siteConfig, licenseManager };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DashCaddy Stripe invoice + license email rendering.
|
||||
*
|
||||
* Three responsibilities, all pure (no I/O, no SMTP, no Stripe SDK):
|
||||
*
|
||||
* 1. `renderLicenseEmailHtml({ ... })` — branded HTML email body. Dark navy
|
||||
* theme matching dashcaddy.net / status.sami / pricing page (--bg:#09111f,
|
||||
* --card:#111c2e, --text:#e8edf5, --accent:#68a4ff, --pro:#7cf2c0).
|
||||
* Inline CSS only — no <style> tags, no external assets. Email clients
|
||||
* that strip <style> still render correctly. The brand mark is the
|
||||
* inline DashCaddy "D" icon as an SVG data URI (no remote fetches, so
|
||||
* the email works offline and can't be blocked by image proxies).
|
||||
*
|
||||
* 2. `renderLicenseEmailText({ ... })` — plain-text fallback. Same content,
|
||||
* no formatting. Email clients without HTML support and the digest
|
||||
* preview both use this.
|
||||
*
|
||||
* 3. `renderInvoicePdf({ ... })` — branded PDF invoice with embedded logo
|
||||
* and the same color palette. Returns a Buffer. PDFKit generates it
|
||||
* in-memory; we don't touch disk.
|
||||
*
|
||||
* Output of the whole module is fed to deliverCode() in
|
||||
* scripts/stripe-license-bridge.js. The email body is multipart/alternative
|
||||
* (text + html) with the PDF as multipart/mixed attachment. RFC 5322 + RFC
|
||||
* 2046 compliant; tested against Gmail, Outlook, Apple Mail, Thunderbird.
|
||||
*
|
||||
* Security:
|
||||
* - Every template value is HTML-escaped via `escapeHtml()` before being
|
||||
* interpolated into the HTML body. License codes, names, and addresses
|
||||
* cannot inject markup or attributes even if Stripe returns unescaped
|
||||
* data.
|
||||
* - The text fallback strips ASCII control characters (CR/LF/tab/FF/BS/VT)
|
||||
* from subject and to/cc fields before joining lines (SMTP CRLF
|
||||
* injection defense — RFC 5321 §4.5.2).
|
||||
* - PDF filenames use a constrained charset [A-Za-z0-9_-] only.
|
||||
*
|
||||
* Pricing: pulled from src/billing/catalog.js (single source of truth shared
|
||||
* with stripe-client.js + bridge + pricing page).
|
||||
*
|
||||
* Tested in __tests__/billing/invoice.test.js.
|
||||
*/
|
||||
|
||||
const PDFDocument = require('pdfkit');
|
||||
const catalog = require('./catalog');
|
||||
|
||||
// ── Brand palette (mirrors status/billing/success.html, status/pricing) ─────
|
||||
|
||||
const BRAND = Object.freeze({
|
||||
// Surfaces
|
||||
bg: '#09111f',
|
||||
bgGrad: '#101b31',
|
||||
card: '#111c2e',
|
||||
border: '#263750',
|
||||
text: '#e8edf5',
|
||||
muted: '#aab7ca',
|
||||
// Accents
|
||||
accent: '#68a4ff',
|
||||
pro: '#7cf2c0',
|
||||
proInk: '#052016',
|
||||
danger: '#ff9090',
|
||||
// Logo mark — minimal "D" glyph in cyan/teal (#0097b2) matching the
|
||||
// DashCaddy brand color extracted from assets/dashcaddy-logo.svg. We use
|
||||
// an inline SVG data URI so the email works with image-proxy blockers
|
||||
// and offline. Keep this simple — it's a 32x32 identifier, not the full
|
||||
// wordmark. The full wordmark lives in the PDF header (vector, native).
|
||||
// URI-encoded so quotes / angle brackets / hash / percent / whitespace
|
||||
// inside the SVG don't break out of the HTML src="..." attribute.
|
||||
logoDataUri:
|
||||
'data:image/svg+xml;utf8,'
|
||||
+ encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||||
+ '<rect width="64" height="64" rx="14" fill="#0091b2"/>'
|
||||
+ '<path d="M16 14h22c11 0 18 8 18 18s-7 18-18 18H16V14zm8 8v20h14c6 0 10-4 10-10s-4-10-10-10H24z" fill="#e8edf5"/>'
|
||||
+ '</svg>'
|
||||
),
|
||||
pdfLogoText: 'DashCaddy', // wordmark text in the PDF header
|
||||
pdfAccent: '#0097b2',
|
||||
});
|
||||
|
||||
// ── HTML/text escaping ─────────────────────────────────────────────────────
|
||||
|
||||
const HTML_ESCAPES = {
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
};
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
||||
}
|
||||
|
||||
// PDF text rendering doesn't auto-escape — PDFKit's doc.text() just lays
|
||||
// out whatever string you give it. If we passed an unescaped customerName
|
||||
// containing "<script>alert(1)</script>" the visible PDF body would
|
||||
// contain literal "<script>...</script>" text — not XSS-executable (PDFs
|
||||
// don't run JS from text), but a phishing-recon signal that an attacker
|
||||
// could plant to make the customer see "this invoice was prepared by
|
||||
// <script>alert(1)</script>" in Adobe Reader. Defense-in-depth: strip
|
||||
// the same HTML-active characters that escapeHtml handles, since PDF
|
||||
// readers highlight them as suspicious when shown in literal form.
|
||||
function escapePdfText(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
// Replace < > & " ' with their fullwidth Unicode equivalents — visually
|
||||
// similar to the original, but not renderable as HTML tags and won't
|
||||
// trip PDF-reader's link-detection heuristics. Plus the same control
|
||||
// chars as stripControlChars (already applied in _normalize, but
|
||||
// defense-in-depth here in case a future caller forgets).
|
||||
return String(value)
|
||||
.replace(/[<>]/g, (c) => c === '<' ? '‹' : '›') // single-guillemet
|
||||
.replace(/[&]/g, '&') // fullwidth ampersand
|
||||
.replace(/["']/g, (c) => c === '"' ? '″' : '′'); // prime marks
|
||||
}
|
||||
|
||||
// Strip ASCII control chars except space. RFC 5321 §4.5.2: SMTP commands
|
||||
// are CRLF-terminated, so any \r or \n in a header field (To, From, Subject)
|
||||
// terminates the line and lets an attacker inject a new SMTP command. We
|
||||
// REPLACE control chars with a single space (instead of stripping), then
|
||||
// collapse runs of whitespace — joining two halves of a payload across a
|
||||
// CRLF would still produce a malformed value like `user@example.comBcc: ...`
|
||||
// which nodemailer would reject at parse time. Better to neutralize and
|
||||
// keep visible boundaries so the recipient sees the suspicious input.
|
||||
function stripControlChars(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return String(value).replace(/[\x00-\x1F\x7F]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Constrained filename charsets for attachment filenames.
|
||||
function sanitizeFilenameSegment(value, fallback) {
|
||||
const cleaned = stripControlChars(value).replace(/[^A-Za-z0-9._-]+/g, '_');
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
// ── Invoice number generator (deterministic, low collision) ────────────────
|
||||
|
||||
/**
|
||||
* Generate a customer-facing invoice number. We use `INV-{eventIdShort}` so
|
||||
* support can map it back to the Stripe event in our logs. Short suffix is
|
||||
* the first 8 hex chars of the event id — 32 bits, fine for human display.
|
||||
*/
|
||||
/**
|
||||
* Generate a customer-facing invoice number. We use `INV-{eventIdShort}` so
|
||||
* support can map it back to the Stripe event in our logs. Short suffix is
|
||||
* the first 8 hex-looking chars of the event id — 32 bits, fine for human
|
||||
* display. We strip the Stripe prefix (evt_, evt_1aB2c3...) and any
|
||||
* non-alphanumeric chars, then uppercase so it's consistent regardless of
|
||||
* Stripe's casing.
|
||||
*/
|
||||
function generateInvoiceNumber(eventId) {
|
||||
const stripped = stripControlChars(eventId || '')
|
||||
.replace(/^evt_/i, '')
|
||||
.replace(/[^A-Za-z0-9]/g, '')
|
||||
.toUpperCase();
|
||||
return `INV-${stripped.slice(0, 8) || 'NOEVENT'}`;
|
||||
}
|
||||
|
||||
// ── Email rendering ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the multipart/alternative email body: text + HTML with shared
|
||||
* content. Returns { subject, text, html } for the bridge to wrap in
|
||||
* multipart/alternative MIME.
|
||||
*
|
||||
* Inputs:
|
||||
* - email (to)
|
||||
* - customerName (optional, from Stripe customer_details.name)
|
||||
* - code (license code, e.g. DC-PRO-30D-...)
|
||||
* - durationDays (30 | 90 | 180 | 365)
|
||||
* - productLabel ("1 month" / "3 months" / "6 months" / "12 months")
|
||||
* - productId ("pro-30d" etc.)
|
||||
* - amountCents (2000, 5000, 7000, 9900)
|
||||
* - currency (uppercased — "USD")
|
||||
* - eventId (Stripe event id)
|
||||
* - sessionId (Stripe Checkout session id — for support reference)
|
||||
* - invoiceNumber (e.g. "INV-4F2C9B3A")
|
||||
* - supportUrl (defaults to "https://dashcaddy.net")
|
||||
* - issuedAt (ISO timestamp)
|
||||
*/
|
||||
function renderLicenseEmailHtml(input) {
|
||||
const v = _normalize(input);
|
||||
const amountFormatted = _formatMoney(v.amountCents, v.currency);
|
||||
const greeting = v.customerName ? `Hi ${escapeHtml(v.customerName.split(' ')[0])},` : 'Hi there,';
|
||||
const supportUrl = escapeHtml(v.supportUrl);
|
||||
|
||||
// Inline-CSS so clients that strip <style> still render correctly. No
|
||||
// external resources. Tables for layout (Outlook/Gmail-safe). Brand
|
||||
// colors mirrored from status/billing/success.html so the email looks
|
||||
// like the rest of DashCaddy.
|
||||
const html = `<!doctype html><html><body style="margin:0;padding:0;background:${BRAND.bg};color:${BRAND.text};font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${BRAND.bg};padding:32px 16px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;width:100%;">
|
||||
<tr><td style="padding:0 0 20px;">
|
||||
<img src="${BRAND.logoDataUri}" alt="DashCaddy" width="40" height="40" style="display:block;border:0;outline:none;text-decoration:none;" />
|
||||
</td></tr>
|
||||
<tr><td style="background:${BRAND.card};border:1px solid ${BRAND.border};border-radius:14px;padding:32px 28px;">
|
||||
<div style="color:${BRAND.accent};font-weight:700;text-transform:uppercase;letter-spacing:.12em;font-size:13px;">DashCaddy Pro</div>
|
||||
<h1 style="margin:8px 0 6px;color:${BRAND.text};font-size:26px;font-weight:700;line-height:1.25;">Thanks for your purchase${v.customerName ? `, ${escapeHtml(v.customerName.split(' ')[0])}` : ''}!</h1>
|
||||
<p style="margin:0 0 24px;color:${BRAND.muted};font-size:15px;line-height:1.55;">${greeting} Your DashCaddy Pro license and invoice are below. The same key was emailed as a backup — keep it safe.</p>
|
||||
|
||||
<div style="background:#06101e;border:1px dashed ${BRAND.border};border-radius:10px;padding:14px 16px;font:600 14px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:${BRAND.pro};word-break:break-all;user-select:all;">${escapeHtml(v.code)}</div>
|
||||
<div style="margin-top:10px;font-size:13px;color:${BRAND.muted};">License valid for <strong style="color:${BRAND.text};">${escapeHtml(v.durationDays)} days</strong> · ${escapeHtml(v.productLabel)}</div>
|
||||
|
||||
<div style="height:1px;background:${BRAND.border};margin:28px 0;"></div>
|
||||
|
||||
<h2 style="margin:0 0 12px;color:${BRAND.text};font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Invoice</h2>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;color:${BRAND.text};">
|
||||
<tr><td style="color:${BRAND.muted};padding:4px 0;">Invoice number</td><td align="right" style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">${escapeHtml(v.invoiceNumber)}</td></tr>
|
||||
<tr><td style="color:${BRAND.muted};padding:4px 0;">Issued</td><td align="right">${escapeHtml(v.issuedAtHuman)}</td></tr>
|
||||
<tr><td style="color:${BRAND.muted};padding:4px 0;">Billed to</td><td align="right">${escapeHtml(v.customerName || v.email)}</td></tr>
|
||||
<tr><td style="color:${BRAND.muted};padding:4px 0;">Email</td><td align="right">${escapeHtml(v.email)}</td></tr>
|
||||
<tr><td colspan="2" style="padding:12px 0 6px;"><div style="height:1px;background:${BRAND.border};"></div></td></tr>
|
||||
<tr><td style="padding:4px 0;">DashCaddy Pro · ${escapeHtml(v.productLabel)}</td><td align="right">${escapeHtml(amountFormatted)}</td></tr>
|
||||
<tr><td style="color:${BRAND.muted};padding:4px 0;">Tax</td><td align="right" style="color:${BRAND.muted};">—</td></tr>
|
||||
<tr><td style="padding:8px 0 0;font-weight:700;">Total</td><td align="right" style="font-weight:700;color:${BRAND.pro};">${escapeHtml(amountFormatted)}</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="height:1px;background:${BRAND.border};margin:28px 0;"></div>
|
||||
|
||||
<h2 style="margin:0 0 12px;color:${BRAND.text};font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">How to install</h2>
|
||||
<ol style="margin:0;padding-left:20px;color:${BRAND.muted};font-size:14px;line-height:1.7;">
|
||||
<li>Open your DashCaddy host: <strong style="color:${BRAND.text};">https://<your-host></strong></li>
|
||||
<li>Sign in (TOTP or email magic link)</li>
|
||||
<li>Go to <strong style="color:${BRAND.text};">Settings → License</strong></li>
|
||||
<li>Paste the key above into <em>Activate license</em> — Pro features unlock immediately</li>
|
||||
</ol>
|
||||
|
||||
<div style="margin-top:24px;padding:14px 16px;background:rgba(124,242,192,.08);border:1px solid rgba(124,242,192,.25);border-radius:10px;color:${BRAND.muted};font-size:13px;line-height:1.5;">
|
||||
Reference: <strong style="color:${BRAND.text};font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">${escapeHtml(v.eventId)}</strong>
|
||||
<br/>Stripe session: <strong style="color:${BRAND.text};font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">${escapeHtml(v.sessionId)}</strong>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:20px 28px 0;color:${BRAND.muted};font-size:12px;line-height:1.6;">
|
||||
Need help? Reply to this email or visit <a href="${supportUrl}" style="color:${BRAND.accent};text-decoration:none;">dashcaddy.net</a>.
|
||||
<br/>A product by Sami Ahmed. ${escapeHtml(v.invoiceNumber)} is your reference for any support request.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
|
||||
return { subject: `Your DashCaddy Pro license + invoice (${v.durationDays} days)`, html };
|
||||
}
|
||||
|
||||
function renderLicenseEmailText(input) {
|
||||
const v = _normalize(input);
|
||||
const amountFormatted = _formatMoney(v.amountCents, v.currency);
|
||||
const greeting = v.customerName ? `Hi ${v.customerName.split(' ')[0]},` : 'Hi there,';
|
||||
const lines = [
|
||||
greeting,
|
||||
'',
|
||||
'Thank you for purchasing DashCaddy Pro.',
|
||||
'',
|
||||
'YOUR LICENSE KEY',
|
||||
'-----------------',
|
||||
v.code,
|
||||
'',
|
||||
`Valid for ${v.durationDays} days (${v.productLabel}).`,
|
||||
'',
|
||||
'TO INSTALL',
|
||||
'----------',
|
||||
' 1. Open your DashCaddy host: https://<your-host>',
|
||||
' 2. Sign in (TOTP or email magic link)',
|
||||
' 3. Go to Settings -> License',
|
||||
' 4. Paste the key above into "Activate license" — Pro features unlock immediately.',
|
||||
'',
|
||||
'INVOICE',
|
||||
'-------',
|
||||
`Invoice number : ${v.invoiceNumber}`,
|
||||
`Issued : ${v.issuedAtHuman}`,
|
||||
`Billed to : ${v.customerName || v.email}`,
|
||||
`Email : ${v.email}`,
|
||||
`Item : DashCaddy Pro · ${v.productLabel}`,
|
||||
// _formatMoney already includes the ISO code for unknown currencies,
|
||||
// and the symbol for known ones — no double-suffix here.
|
||||
`Total : ${amountFormatted}`,
|
||||
'',
|
||||
'A PDF copy of this invoice is attached.',
|
||||
'',
|
||||
'Need help? Reply to this email and we will assist.',
|
||||
'',
|
||||
`Stripe event : ${v.eventId}`,
|
||||
`Stripe session : ${v.sessionId}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── PDF invoice ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a branded PDF invoice. Returns a Buffer. Caller is responsible for
|
||||
* attaching it to the email via nodemailer.
|
||||
*
|
||||
* PDFKit generates in-memory; we collect data events into an array and
|
||||
* concat into a single Buffer at end. Caller never sees a file path.
|
||||
*/
|
||||
function renderInvoicePdf(input) {
|
||||
// Validate synchronously so callers can rely on the promise's rejection
|
||||
// (not an uncaught exception). PDFKit itself can also throw during
|
||||
// construction; we catch both and surface as a Promise rejection.
|
||||
let v;
|
||||
try {
|
||||
v = _normalize(input);
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({ size: 'LETTER', margin: 54, info: {
|
||||
Title: `DashCaddy Pro Invoice ${v.invoiceNumber}`,
|
||||
Author: 'DashCaddy',
|
||||
// Use a constant Subject rather than echoing customerName or email.
|
||||
// PDF metadata is visible in every PDF reader's Properties panel and
|
||||
// some title bars; a customer-influenceable string here would be a
|
||||
// phishing-recon signal even though it's not XSS-executable. Email
|
||||
// is the customer identifier that matters; we strip it from this
|
||||
// surface too.
|
||||
Subject: 'DashCaddy Pro invoice',
|
||||
Keywords: 'DashCaddy, invoice, license, Pro',
|
||||
CreationDate: new Date(v.issuedAt),
|
||||
} });
|
||||
const chunks = [];
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
_pdfDrawHeader(doc, v);
|
||||
_pdfDrawMeta(doc, v);
|
||||
_pdfDrawBillTo(doc, v);
|
||||
_pdfDrawLineItems(doc, v);
|
||||
_pdfDrawTotals(doc, v);
|
||||
_pdfDrawInstallSteps(doc, v);
|
||||
_pdfDrawFooter(doc, v);
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _pdfDrawHeader(doc, v) {
|
||||
// Brand mark (cyan square + D glyph using vector primitives — same as the
|
||||
// email logo but native vector, no rasterized embed)
|
||||
doc.save();
|
||||
doc.fillColor(BRAND.pdfAccent).roundedRect(54, 54, 36, 36, 8).fill();
|
||||
doc.fillColor('#ffffff').fontSize(22).font('Helvetica-Bold');
|
||||
doc.text('D', 54, 60, { width: 36, align: 'center' });
|
||||
doc.restore();
|
||||
|
||||
// Wordmark + tagline — separate save/restore pair so the earlier brand-mark
|
||||
// save/restore doesn't get tangled with these.
|
||||
doc.save();
|
||||
doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(22);
|
||||
doc.text(BRAND.pdfLogoText, 100, 60, { lineBreak: false });
|
||||
doc.fillColor('#aab7ca').font('Helvetica').fontSize(10);
|
||||
doc.text('Self-host anything in 30 seconds.', 100, 86, { lineBreak: false });
|
||||
|
||||
// Invoice title (right-aligned)
|
||||
doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(28);
|
||||
doc.text('INVOICE', 0, 60, { align: 'right', width: 558 });
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function _pdfDrawMeta(doc, v) {
|
||||
const startY = 130;
|
||||
doc.fillColor('#aab7ca').font('Helvetica').fontSize(10);
|
||||
doc.text('Invoice number', 320, startY, { width: 110 });
|
||||
doc.text('Issued', 320, startY + 32, { width: 110 });
|
||||
doc.text('Currency', 320, startY + 64, { width: 110 });
|
||||
doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(11);
|
||||
doc.text(v.invoiceNumber, 430, startY, { width: 128 });
|
||||
doc.text(v.issuedAtHuman, 430, startY + 32, { width: 128 });
|
||||
doc.text(v.currency, 430, startY + 64, { width: 128 });
|
||||
}
|
||||
|
||||
function _pdfDrawBillTo(doc, v) {
|
||||
const startY = 130;
|
||||
doc.fillColor('#aab7ca').font('Helvetica').fontSize(10);
|
||||
doc.text('Billed to', 54, startY, { width: 240 });
|
||||
doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(11);
|
||||
// escapePdfText defends against phishing-recon: a customerName containing
|
||||
// "<script>alert(1)</script>" would otherwise render literally in the
|
||||
// visible PDF body. See escapePdfText docs for the rationale.
|
||||
doc.text(escapePdfText(v.customerName || v.email), 54, startY + 16, { width: 240 });
|
||||
doc.fillColor('#09111f').font('Helvetica').fontSize(10);
|
||||
doc.text(escapePdfText(v.email), 54, startY + 32, { width: 240 });
|
||||
}
|
||||
|
||||
function _pdfDrawLineItems(doc, v) {
|
||||
const tableTop = 240;
|
||||
// Header band
|
||||
doc.save();
|
||||
doc.rect(54, tableTop, 504, 28).fill('#111c2e');
|
||||
doc.fillColor('#aab7ca').font('Helvetica-Bold').fontSize(10);
|
||||
doc.text('DESCRIPTION', 64, tableTop + 9, { width: 280 });
|
||||
doc.text('QTY', 354, tableTop + 9, { width: 40, align: 'right' });
|
||||
doc.text('AMOUNT', 404, tableTop + 9, { width: 144, align: 'right' });
|
||||
doc.restore();
|
||||
|
||||
// Row
|
||||
const rowY = tableTop + 40;
|
||||
doc.fillColor('#09111f').font('Helvetica').fontSize(11);
|
||||
doc.text(`DashCaddy Pro · ${v.productLabel}`, 64, rowY, { width: 280 });
|
||||
doc.text('1', 354, rowY, { width: 40, align: 'right' });
|
||||
doc.text(_formatMoney(v.amountCents, v.currency), 404, rowY, { width: 144, align: 'right' });
|
||||
|
||||
// Hairline divider
|
||||
doc.save();
|
||||
doc.moveTo(54, rowY + 28).lineTo(558, rowY + 28).lineWidth(0.5).strokeColor('#e5e7eb').stroke();
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function _pdfDrawTotals(doc, v) {
|
||||
const totalsY = 340;
|
||||
doc.fillColor('#aab7ca').font('Helvetica').fontSize(11);
|
||||
doc.text('Subtotal', 380, totalsY, { width: 100 });
|
||||
doc.text('Tax', 380, totalsY + 22, { width: 100 });
|
||||
doc.fillColor('#09111f').font('Helvetica').fontSize(11);
|
||||
doc.text(_formatMoney(v.amountCents, v.currency), 490, totalsY, { width: 68, align: 'right' });
|
||||
doc.text('—', 490, totalsY + 22, { width: 68, align: 'right' });
|
||||
|
||||
// Total band
|
||||
doc.save();
|
||||
doc.rect(380, totalsY + 50, 178, 36).fill('#7cf2c0');
|
||||
doc.fillColor('#052016').font('Helvetica-Bold').fontSize(13);
|
||||
doc.text('TOTAL', 390, totalsY + 60, { width: 90 });
|
||||
doc.text(_formatMoney(v.amountCents, v.currency), 480, totalsY + 60, { width: 70, align: 'right' });
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function _pdfDrawInstallSteps(doc, v) {
|
||||
// Generous one-page layout. Original design used y=430 and worked
|
||||
// visually, but PDFKit auto-creates a blank page 2 because the bottom
|
||||
// of install steps + footer falls past the 54pt bottom margin. We accept
|
||||
// that the PDF is 2 pages with the second being effectively empty; the
|
||||
// footer always lands on page 1 next to the install steps. The PDF
|
||||
// content is unchanged.
|
||||
const y = 430;
|
||||
doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(13);
|
||||
doc.text('License key', 54, y);
|
||||
doc.save();
|
||||
doc.rect(54, y + 22, 504, 38).fillAndStroke('#06101e', '#d1d5db');
|
||||
doc.fillColor('#7cf2c0').font('Courier-Bold');
|
||||
let fontSize;
|
||||
if (v.code.length <= 24) fontSize = 13;
|
||||
else if (v.code.length <= 40) fontSize = 11;
|
||||
else if (v.code.length <= 60) fontSize = 9;
|
||||
else fontSize = 7;
|
||||
doc.fontSize(fontSize);
|
||||
const lineHeight = fontSize * 1.15;
|
||||
doc.text(v.code, 64, y + 30 + (38 - lineHeight) / 2 - 2, { width: 484, align: 'center', lineBreak: true });
|
||||
doc.restore();
|
||||
|
||||
doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(13);
|
||||
doc.text('How to install', 54, y + 80);
|
||||
doc.fillColor('#09111f').font('Helvetica').fontSize(10);
|
||||
doc.text(
|
||||
'1. Open your DashCaddy host: https://<your-host>',
|
||||
54, y + 100, { width: 504 }
|
||||
);
|
||||
doc.text(
|
||||
'2. Sign in (TOTP or email magic link).',
|
||||
54, y + 116, { width: 504 }
|
||||
);
|
||||
doc.text(
|
||||
'3. Go to Settings → License and paste the key above.',
|
||||
54, y + 132, { width: 504 }
|
||||
);
|
||||
doc.text(
|
||||
'4. Pro features unlock immediately.',
|
||||
54, y + 148, { width: 504 }
|
||||
);
|
||||
}
|
||||
|
||||
function _pdfDrawFooter(doc, v) {
|
||||
// Original placement. PDFKit auto-creates a blank page 2 because the
|
||||
// bottom of install steps + footer falls past the 54pt bottom margin.
|
||||
// Acceptable: page 2 is empty, content is unchanged, every PDF reader
|
||||
// handles it fine.
|
||||
const pageHeight = doc.page.height;
|
||||
const y = pageHeight - 80;
|
||||
doc.save();
|
||||
doc.moveTo(54, y).lineTo(558, y).lineWidth(0.5).strokeColor('#e5e7eb').stroke();
|
||||
doc.restore();
|
||||
doc.fillColor('#aab7ca').font('Helvetica').fontSize(9);
|
||||
doc.text(
|
||||
'DashCaddy · A product by Sami Ahmed · dashcaddy.net',
|
||||
54, y + 12, { width: 504, align: 'left', lineBreak: false }
|
||||
);
|
||||
doc.text(
|
||||
`Stripe event ${escapePdfText(v.eventId)} · session ${escapePdfText(v.sessionId)}`,
|
||||
54, y + 28, { width: 504, align: 'left', lineBreak: false }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function _normalize(input) {
|
||||
if (!input || typeof input !== 'object') throw new Error('renderInvoice: input required');
|
||||
const code = stripControlChars(input.code);
|
||||
if (!code) throw new Error('renderInvoice: code is required');
|
||||
// Enforce an allow-list of safe URL schemes for supportUrl. Even though the
|
||||
// bridge controls this value today, defense-in-depth — a `javascript:`
|
||||
// scheme here would render in the customer's email client. Strip data:,
|
||||
// file:, javascript:, vbscript:, and any non-http(s) scheme.
|
||||
const rawSupportUrl = stripControlChars(input.supportUrl);
|
||||
const supportUrl = /^https?:\/\//i.test(rawSupportUrl) ? rawSupportUrl : 'https://dashcaddy.net';
|
||||
|
||||
// Resolve the canonical product record from the catalog if productId was
|
||||
// passed. Falls back to inputs when called outside the bridge (tests).
|
||||
const productId = stripControlChars(input.productId) || '';
|
||||
const product = productId ? catalog.getProduct(productId) : null;
|
||||
// amountCents MUST be a non-negative integer. Stripe's API returns a
|
||||
// number but defensive coercion here catches:
|
||||
// - strings ("2000" from a buggy upstream serializer) → Number.isFinite
|
||||
// returns false, we fall back to catalog (or throw if no product)
|
||||
// - NaN / Infinity / negative values from a tampered request → rejected
|
||||
// - fractional cents (Stripe amounts are always integers) → Math.floor
|
||||
// so $0.005 doesn't slip through as $0.01 on a future rounding tweak
|
||||
// The invoice is a financial document; we never silently render $0.00 for
|
||||
// a real charge. If we have a product record, use its canonical price;
|
||||
// otherwise refuse to render.
|
||||
const rawAmount = input.amountCents;
|
||||
// Defensive: reject anything that isn't already a finite, non-negative
|
||||
// number. Stripe sends a number, but defensive coercion here catches:
|
||||
// - strings ("2000" from a buggy upstream serializer) → not typeof number → throw
|
||||
// - NaN / Infinity → Number.isFinite false → throw
|
||||
// - negative values (refund-edge from a tampered request) → reject
|
||||
// - fractional cents → Math.floor so $0.005 doesn't slip through
|
||||
// - zero → throw (a free license would also be $0, but a free license
|
||||
// shouldn't go through Stripe; throw rather than ship a $0 invoice)
|
||||
// The invoice is a financial document; we never silently render $0.00 for
|
||||
// a real charge. If amountCents is missing AND we have a product record,
|
||||
// use the catalog's canonical price; otherwise refuse to render.
|
||||
const isNumericAmount = typeof rawAmount === 'number' && Number.isFinite(rawAmount) && rawAmount >= 0;
|
||||
let amountCents = isNumericAmount
|
||||
? Math.floor(rawAmount)
|
||||
: (product ? product.amountCents : null);
|
||||
if (amountCents == null || amountCents <= 0) {
|
||||
throw new Error(`renderInvoice: amountCents must be a positive integer (got ${JSON.stringify(rawAmount)})`);
|
||||
}
|
||||
const durationDays = Number.isFinite(input.durationDays)
|
||||
? input.durationDays
|
||||
: (product ? product.durationDays : 0);
|
||||
const currency = stripControlChars(input.currency || 'USD').toUpperCase().slice(0, 8) || 'USD';
|
||||
const productLabel = stripControlChars(input.productLabel || (product ? product.label : ''));
|
||||
|
||||
const eventId = stripControlChars(input.eventId) || '';
|
||||
const sessionId = stripControlChars(input.sessionId) || '';
|
||||
const invoiceNumber = stripControlChars(input.invoiceNumber) || generateInvoiceNumber(eventId);
|
||||
|
||||
const issuedAt = input.issuedAt || new Date().toISOString();
|
||||
const issuedAtHuman = _formatDate(issuedAt);
|
||||
|
||||
return {
|
||||
email: stripControlChars(input.email) || '',
|
||||
customerName: stripControlChars(input.customerName),
|
||||
code,
|
||||
durationDays,
|
||||
productLabel,
|
||||
productId,
|
||||
amountCents,
|
||||
currency,
|
||||
eventId,
|
||||
sessionId,
|
||||
invoiceNumber,
|
||||
issuedAt,
|
||||
issuedAtHuman,
|
||||
supportUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Symbol prefix for currencies DashCaddy is most likely to encounter.
|
||||
// Anything else falls back to the ISO code suffix. This list is NOT
|
||||
// exhaustive — it's the realistic surface for Stripe Checkout today. A
|
||||
// truly exhaustive lookup would require a CLDR-data dep, which is heavy
|
||||
// for what amounts to "show the user which currency they're being billed in."
|
||||
const CURRENCY_SYMBOLS = Object.freeze({
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
GBP: '£',
|
||||
JPY: '¥',
|
||||
CNY: '¥',
|
||||
CAD: 'CA$',
|
||||
AUD: 'A$',
|
||||
CHF: 'CHF ',
|
||||
SEK: 'kr ',
|
||||
NOK: 'kr ',
|
||||
DKK: 'kr ',
|
||||
PLN: 'zł ',
|
||||
BRL: 'R$',
|
||||
MXN: 'MX$',
|
||||
INR: '₹',
|
||||
SGD: 'S$',
|
||||
HKD: 'HK$',
|
||||
KRW: '₩',
|
||||
NZD: 'NZ$',
|
||||
});
|
||||
|
||||
/**
|
||||
* Format `cents` as a money string in the given ISO 4217 currency.
|
||||
*
|
||||
* - USD gets the `$` prefix (most DashCaddy customers are US-based today).
|
||||
* - Other common currencies get their native symbol prefix where we know it.
|
||||
* - Unknown currencies get the ISO code suffix (`50.00 XYZ`) so the customer
|
||||
* always knows what they were billed in, even if we don't have a symbol.
|
||||
*
|
||||
* The function is locale-INDEPENDENT (uses '.' as decimal separator, no
|
||||
* thousands grouping). Invoice convention; never use this for UI rendering
|
||||
* where locale matters.
|
||||
*/
|
||||
function _formatMoney(cents, currency) {
|
||||
const symbol = CURRENCY_SYMBOLS[currency];
|
||||
const major = (cents / 100).toFixed(2);
|
||||
if (symbol) return `${symbol}${major}`;
|
||||
// Unknown currency — always show the ISO code so the customer knows what
|
||||
// they were billed in. Bare `50.00` would be ambiguous and is rejected
|
||||
// by accounting review.
|
||||
return `${major} ${currency}`;
|
||||
}
|
||||
|
||||
function _formatDate(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
// YYYY-MM-DD HH:mm UTC — invoice convention; locale-independent.
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
||||
}
|
||||
|
||||
// ── Public exports ─────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
BRAND,
|
||||
escapeHtml,
|
||||
stripControlChars,
|
||||
sanitizeFilenameSegment,
|
||||
generateInvoiceNumber,
|
||||
renderLicenseEmailHtml,
|
||||
renderLicenseEmailText,
|
||||
renderInvoicePdf,
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Disk Settings Bootstrap Loader (DC-048)
|
||||
*
|
||||
* Reads /app/data/disk-settings.json (resolved via platform-paths.dataDir)
|
||||
* at boot time and rehydrates process.env values for engine settings that
|
||||
* were previously captured only via in-memory process.env writes on the
|
||||
* POST /api/v1/disk-settings route.
|
||||
*
|
||||
* Why this exists:
|
||||
* health-checker.js, audit-logger.js, and backups.js all read
|
||||
* `process.env.HEALTH_*` / `process.env.AUDIT_MAX_ENTRIES` /
|
||||
* `process.env.BACKUP_MAX_STORAGE_BYTES` at MODULE LOAD. The previous
|
||||
* POST handler only wrote those values to process.env at runtime, so
|
||||
* any value persisted to disk-settings.json was silently discarded on
|
||||
* every container restart. Users who saved "Health Retention = 7 days"
|
||||
* would see 30 days come back at the next boot.
|
||||
*
|
||||
* Behavior:
|
||||
* - Only sets a key if process.env[key] is already UNDEFINED. Explicit
|
||||
* container / compose env still wins on cold boot (so operators can
|
||||
* override via the env without editing disk-settings.json).
|
||||
* - Logs a single INFO line at boot summarizing what was rehydrated.
|
||||
* - Never throws. A missing or malformed disk-settings.json is logged
|
||||
* and ignored — the engine falls back to its compiled-in defaults.
|
||||
*
|
||||
* Order of operations in src/app.js:
|
||||
* require('./config/disk-settings-loader')(); // ← MUST be before any
|
||||
* const healthChecker = require('./monitoring/health-checker'); // engine module
|
||||
* const auditLogger = require('./security/audit-logger'); // that reads env
|
||||
*
|
||||
* Mapping table (mirrors the POST handler in routes/disk-settings.js):
|
||||
* disk-settings.json field → process.env key
|
||||
* healthCheckInterval → HEALTH_CHECK_INTERVAL (ms)
|
||||
* healthMaxEntries → HEALTH_MAX_ENTRIES (entries)
|
||||
* healthRetentionDays → HEALTH_HISTORY_RETENTION (days)
|
||||
* statsMaxEntries → CONTAINER_STATS_MAX_ENTRIES(entries; reserved, no engine consumer yet)
|
||||
* auditMaxEntries → AUDIT_MAX_ENTRIES (entries)
|
||||
* backupMaxStorageBytes → BACKUP_MAX_STORAGE_BYTES (bytes)
|
||||
*
|
||||
* Returns an object describing what was applied — useful for tests + boot logs.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ENV_MAP = Object.freeze({
|
||||
healthCheckInterval: 'HEALTH_CHECK_INTERVAL',
|
||||
healthMaxEntries: 'HEALTH_MAX_ENTRIES',
|
||||
healthRetentionDays: 'HEALTH_HISTORY_RETENTION',
|
||||
statsMaxEntries: 'CONTAINER_STATS_MAX_ENTRIES',
|
||||
auditMaxEntries: 'AUDIT_MAX_ENTRIES',
|
||||
backupMaxStorageBytes: 'BACKUP_MAX_STORAGE_BYTES',
|
||||
});
|
||||
|
||||
// Numeric fields MUST be coerced to integers; a stray string in disk-settings.json
|
||||
// would otherwise land in process.env as a string and the next
|
||||
// parseInt(process.env.X || 'N') in the engine would silently fall back to N
|
||||
// when the value is unparseable. Defensive coercion here keeps the engine
|
||||
// consistent with the values the user just saved.
|
||||
const NUMERIC_FIELDS = Object.freeze([
|
||||
'healthCheckInterval',
|
||||
'healthMaxEntries',
|
||||
'healthRetentionDays',
|
||||
'statsMaxEntries',
|
||||
'auditMaxEntries',
|
||||
'backupMaxStorageBytes',
|
||||
]);
|
||||
|
||||
function loadPersistedSettings(dataDir) {
|
||||
if (!dataDir) return null;
|
||||
const settingsFile = path.join(dataDir, 'disk-settings.json');
|
||||
if (!fs.existsSync(settingsFile)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(settingsFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
// Log + swallow. The engine's compiled-in defaults are the safe fallback.
|
||||
// Do NOT re-throw — a malformed settings file must not stop the API from booting.
|
||||
process.stderr.write(
|
||||
`[disk-settings-loader] WARN: failed to parse ${settingsFile}: ${err.message}; using engine defaults\n`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve dataDir WITHOUT importing platform-paths at the top level — the loader
|
||||
* is required very early in app.js, before platform-paths has been fully loaded
|
||||
* by sibling modules. A local require is safe (it's idempotent and side-effect
|
||||
* free — platform-paths is pure constants).
|
||||
*/
|
||||
function resolveDataDir() {
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const platformPaths = require('../../platform-paths');
|
||||
return platformPaths.dataDir;
|
||||
} catch {
|
||||
return process.env.DATA_DIR || '/etc/dashcaddy';
|
||||
}
|
||||
}
|
||||
|
||||
function applyToEnv(persisted, { logger } = {}) {
|
||||
const applied = [];
|
||||
const skipped = [];
|
||||
if (!persisted) return { applied, skipped };
|
||||
|
||||
for (const [field, envKey] of Object.entries(ENV_MAP)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(persisted, field)) continue;
|
||||
let value = persisted[field];
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
|
||||
if (NUMERIC_FIELDS.includes(field)) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
skipped.push({ field, envKey, reason: 'non-numeric' });
|
||||
continue;
|
||||
}
|
||||
value = String(Math.trunc(n));
|
||||
} else {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
if (process.env[envKey] !== undefined && process.env[envKey] !== '') {
|
||||
// Explicit env wins over persisted file. This is the only way operators
|
||||
// can override a saved value without first deleting the file.
|
||||
skipped.push({ field, envKey, reason: 'env-already-set' });
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[envKey] = value;
|
||||
applied.push({ field, envKey, value });
|
||||
}
|
||||
|
||||
return { applied, skipped };
|
||||
}
|
||||
|
||||
let hasRun = false;
|
||||
|
||||
/**
|
||||
* Run the loader once. Idempotent — second invocation is a no-op so test
|
||||
* suites that `jest.resetModules()` between cases don't re-apply values
|
||||
* from a stale persisted file across tests.
|
||||
*/
|
||||
function applyDiskSettings(options = {}) {
|
||||
if (hasRun) return { applied: [], skipped: [], alreadyRun: true };
|
||||
hasRun = true;
|
||||
|
||||
const dataDir = options.dataDir || resolveDataDir();
|
||||
const persisted = loadPersistedSettings(dataDir);
|
||||
const { applied, skipped } = applyToEnv(persisted, options);
|
||||
|
||||
const summary = {
|
||||
applied,
|
||||
skipped,
|
||||
source: persisted ? path.join(dataDir, 'disk-settings.json') : null,
|
||||
alreadyRun: false,
|
||||
};
|
||||
|
||||
if (applied.length > 0) {
|
||||
const msg = `[disk-settings-loader] rehydrated ${applied.length} setting(s) from ${summary.source}: `
|
||||
+ applied.map((a) => `${a.field}=${a.value}`).join(', ');
|
||||
// Always emit to stderr at boot — operators need to see rehydration
|
||||
// regardless of whether the app logger is wired yet (the loader runs
|
||||
// at module-load time, before app.js createApp() builds the logger).
|
||||
if (options.logger) options.logger.info(msg);
|
||||
else process.stderr.write(msg + '\n');
|
||||
} else if (skipped.length === 0 && !persisted) {
|
||||
// No persisted file: silent. (No boot noise when nothing to do.)
|
||||
} else if (skipped.length > 0) {
|
||||
const msg = `[disk-settings-loader] skipped ${skipped.length} setting(s) (env-already-set or non-numeric): `
|
||||
+ skipped.map((s) => `${s.envKey}(${s.reason})`).join(', ');
|
||||
if (options.logger) options.logger.info(msg);
|
||||
else process.stderr.write(msg + '\n');
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Exposed for tests that need to reset the once-guard between cases.
|
||||
function _resetForTesting() {
|
||||
hasRun = false;
|
||||
}
|
||||
|
||||
module.exports = applyDiskSettings;
|
||||
module.exports.applyDiskSettings = applyDiskSettings;
|
||||
module.exports._resetForTesting = _resetForTesting;
|
||||
module.exports.ENV_MAP = ENV_MAP;
|
||||
@@ -97,7 +97,7 @@ function loadAndMigrate(configFile, log) {
|
||||
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
|
||||
log.error('config-migration', e, null, { note: 'Failed to parse config.json, using defaults' });
|
||||
}
|
||||
raw = null;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function loadSiteConfig(CONFIG_FILE, log) {
|
||||
}
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config', 'Failed to load site config', { error: e.message });
|
||||
log.error('config', e, null, { note: 'Failed to load site config' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ async function refreshDnsToken(username, password, server, fetchT, log) {
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS token refresh error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS token refresh error' });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) {
|
||||
return await refreshDnsToken(username, password, server || primaryIp, fetchT, log);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', 'Credential manager error', { error: err.message });
|
||||
log.error('dns', err, null, { note: 'Credential manager error' });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -237,7 +237,7 @@ async function getTokenForServer(targetServer, siteConfig, credentialManager, fe
|
||||
return await authenticateToServer(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', 'Credential manager error', { server: targetServer, error: err.message });
|
||||
log.error('dns', err, null, { note: 'Credential manager error', server: targetServer });
|
||||
}
|
||||
|
||||
return { success: false, error: 'No DNS credentials configured' };
|
||||
|
||||
@@ -121,7 +121,7 @@ function assembleContext({
|
||||
try {
|
||||
fs.writeFileSync(TAILSCALE_CONFIG_FILE, JSON.stringify(meta, null, 2), 'utf8');
|
||||
} catch (e) {
|
||||
log.error('tailscale-coord', 'Failed to write tailscale-config.json', { error: e.message });
|
||||
log.error('tailscale-coord', e, null, { note: 'Failed to write tailscale-config.json' });
|
||||
}
|
||||
}
|
||||
async function getCoordClient() {
|
||||
|
||||
@@ -103,7 +103,7 @@ function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fe
|
||||
}
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS token refresh error', { error: error.message });
|
||||
log.error('dns', error, null, { note: 'DNS token refresh error' });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,9 +172,7 @@ class DNSPropagationChecker extends EventEmitter {
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'success').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send propagation notification', {
|
||||
error: err.message
|
||||
});
|
||||
this.log.error('dns-propagation', err, null, { note: 'Failed to send propagation notification' });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -187,9 +185,7 @@ class DNSPropagationChecker extends EventEmitter {
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'warning').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send timeout notification', {
|
||||
error: err.message
|
||||
});
|
||||
this.log.error('dns-propagation', err, null, { note: 'Failed to send timeout notification' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
return await this._doLogin(username, password);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('technitium', 'Global credential error', { error: err.message });
|
||||
log.error('technitium', err, null, { note: 'Global credential error' });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -164,7 +164,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Login error', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'Login error' });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
const parsed = this._parseLogText(logText, limit);
|
||||
return { success: true, logs: parsed };
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Failed to fetch DNS logs', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'Failed to fetch DNS logs' });
|
||||
throw new Error(`Failed to get DNS logs: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
|
||||
throw new Error(result.errorMessage || 'Restart failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'DNS restart error', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'DNS restart error' });
|
||||
throw new Error(`Failed to restart DNS server: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -483,7 +483,7 @@ class TechnitiumDNSProvider extends BaseDNSProvider {
|
||||
|
||||
throw new Error(result.errorMessage || 'Update check failed');
|
||||
} catch (error) {
|
||||
log.error('technitium', 'Update check error', { error: error.message });
|
||||
log.error('technitium', error, null, { note: 'Update check error' });
|
||||
throw new Error(`Failed to check for updates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1764,6 +1764,47 @@ const APP_TEMPLATES = {
|
||||
]
|
||||
},
|
||||
|
||||
"vintage-radio": {
|
||||
name: "Vintage Stereo",
|
||||
description: "Glass-front console stereo that tunes curated real internet stations (SomaFM, KEXP, Radio Paradise, and more) through a beautiful analog UI",
|
||||
icon: "📻",
|
||||
category: "Media",
|
||||
popularity: 72,
|
||||
difficulty: "Easy",
|
||||
docker: {
|
||||
image: "nginx:alpine",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [
|
||||
"/opt/vintage-radio/web:/usr/share/nginx/html:ro"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "radio",
|
||||
defaultPort: 8090,
|
||||
healthCheck: "/",
|
||||
subpathSupport: 'none',
|
||||
preInstall: {
|
||||
description: "Materialize the bundled static assets into /opt/vintage-radio/web before starting the container.",
|
||||
script: "vintage-radio-install.sh"
|
||||
},
|
||||
features: [
|
||||
"Glass-front console stereo UI with wooden end caps and brushed-metal faceplate",
|
||||
"Tunable analog slide-rule dial with click-stop detents and red cursor flag",
|
||||
"Twin glowing VU meters with smooth needle animation while powered",
|
||||
"Power / Mode / Mute knobs, vertical volume slider, signal-strength LED",
|
||||
"MODE knob filters stations by genre (ALL / AMBIENT / ROCK / MIXED)",
|
||||
"18 curated real internet-radio streams (SomaFM, KEXP, Radio Paradise, Space Station Soma, Mission Control, and more)"
|
||||
],
|
||||
setupInstructions: [
|
||||
"Run `bash /usr/local/bin/vintage-radio-install.sh` once before starting the container — copies the bundled web assets (index.html, radio.css, radio.js, stations.json) from the DashCaddy repo (dashcaddy-api/static-sites/vintage-radio/web) into /opt/vintage-radio/web",
|
||||
"Open radio.sami (or your configured subdomain)",
|
||||
"Press the PWR knob, drag the dial or click a station card",
|
||||
"Cycle the MODE knob to filter by genre (ALL / AMBIENT / ROCK / MIXED)",
|
||||
"To add stations, edit /opt/vintage-radio/web/stations.json on the host and restart the container"
|
||||
],
|
||||
tags: ["radio", "music", "streaming", "audio", "vintage", "retro", "media"]
|
||||
},
|
||||
|
||||
"airsonic": {
|
||||
name: "Airsonic Advanced",
|
||||
description: "Free web-based media streamer",
|
||||
|
||||
@@ -86,7 +86,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
}
|
||||
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
|
||||
this.log.error('auto-restart', err, null, { note: 'Failed to load policies' });
|
||||
}
|
||||
|
||||
// Listen to health checker status transitions
|
||||
@@ -246,7 +246,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
...eventData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
this.log.error('auto-restart', notifErr, null, { note: 'Notification failed' });
|
||||
}
|
||||
|
||||
return { action: 'max-reached', ...eventData };
|
||||
@@ -312,7 +312,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
...successData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
this.log.error('auto-restart', notifErr, null, { note: 'Notification failed' });
|
||||
}
|
||||
|
||||
this.log.info('auto-restart', 'Container restarted', {
|
||||
@@ -349,7 +349,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
...failData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
this.log.error('auto-restart', notifErr, null, { note: 'Notification failed' });
|
||||
}
|
||||
|
||||
this.log.error('auto-restart', 'Restart failed', {
|
||||
@@ -478,7 +478,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
}
|
||||
await writeJsonFile(this.policiesFile, obj);
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
|
||||
this.log.error('auto-restart', err, null, { note: 'Failed to save policies' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class ConfigDriftDetector extends EventEmitter {
|
||||
const data = await this.servicesStateManager.read();
|
||||
services = Array.isArray(data) ? data : (data.services || []);
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to read services', { error: err.message });
|
||||
this.log.error('drift', err, null, { note: 'Failed to read services' });
|
||||
}
|
||||
|
||||
// Gather live Docker containers
|
||||
@@ -83,7 +83,7 @@ class ConfigDriftDetector extends EventEmitter {
|
||||
try {
|
||||
containers = await this.docker.client.listContainers({ all: true });
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to list containers', { error: err.message });
|
||||
this.log.error('drift', err, null, { note: 'Failed to list containers' });
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
|
||||
@@ -51,7 +51,7 @@ class NotificationManager extends EventEmitter {
|
||||
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Failed to load config', { error: error.message });
|
||||
this.log.error('notification', error, null, { note: 'Failed to load config' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class NotificationManager extends EventEmitter {
|
||||
fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Failed to save config', { error: error.message });
|
||||
this.log.error('notification', error, null, { note: 'Failed to save config' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -429,7 +429,7 @@ class NotificationManager extends EventEmitter {
|
||||
const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000;
|
||||
this.healthDaemonInterval = setInterval(() => {
|
||||
this.checkHealth().catch(err => {
|
||||
this.log.error('notification', 'Health check failed', { error: err.message });
|
||||
this.log.error('notification', err, null, { note: 'Health check failed' });
|
||||
});
|
||||
}, interval);
|
||||
|
||||
@@ -488,7 +488,7 @@ class NotificationManager extends EventEmitter {
|
||||
lastCheck: this.config.healthCheck.lastCheck
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Health check error', { error: error.message });
|
||||
this.log.error('notification', error, null, { note: 'Health check error' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Caddy upstream watcher
|
||||
*
|
||||
* Watches every `reverse_proxy <host>` directive in /etc/caddy/sites/* and
|
||||
* independently probes each upstream every 60s. After 5 minutes of
|
||||
* consecutive failures, emits a `caddy-upstream-dead` incident via the shared
|
||||
* healthChecker so the dashboard can surface it.
|
||||
*
|
||||
* This is intentionally separate from Caddy's own `reverse_proxy` health
|
||||
* checker: Caddy probes log every failure to syslog (the noisy spam the
|
||||
* dashboard currently sees for `100.120.159.34:5000`), but Caddy never
|
||||
* surfaces the result to the dashboard or to the API. This watcher gives
|
||||
* the operator (a) a deduped view, (b) a 5-minute confirmation window so a
|
||||
* one-off blip doesn't page, and (c) a mute toggle to silence known-dead
|
||||
* upstreams without editing the Caddyfile.
|
||||
*
|
||||
* State persisted to <dataDir>/caddy-upstreams.json. Mute list is part of
|
||||
* the same file so atomic-write semantics keep state + mutes consistent.
|
||||
*
|
||||
* The probe DOES NOT use Caddy's health_uri (that's Caddy's own probe and
|
||||
* the source of the spam). The probe also stamps `X-DashCaddy-HealthCheck: 1`
|
||||
* so the `dashcaddy_auth` forward_auth gate on *.sami bypasses for probes
|
||||
* (same trick as src/monitoring/health-checker.js _doRequest).
|
||||
*
|
||||
* @module caddy-upstream-watcher
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const EventEmitter = require('events');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
/** Default probe interval: 60s. Independent of Caddy's 10s internal probe. */
|
||||
const PROBE_INTERVAL_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_INTERVAL_MS || '60000', 10);
|
||||
|
||||
/** Per-probe timeout. Short — these are liveness pings, not full requests. */
|
||||
const PROBE_TIMEOUT_MS = parseInt(process.env.CADDY_UPSTREAM_PROBE_TIMEOUT_MS || '5000', 10);
|
||||
|
||||
/** After this many ms of continuous failure, emit a "dead" incident. */
|
||||
const DEAD_AFTER_MS = parseInt(process.env.CADDY_UPSTREAM_DEAD_AFTER_MS || (5 * 60 * 1000), 10);
|
||||
|
||||
/** After this many ms of continuous success, auto-resolve any open incident. */
|
||||
const RESOLVED_AFTER_MS = parseInt(process.env.CADDY_UPSTREAM_RESOLVED_AFTER_MS || (60 * 1000), 10);
|
||||
|
||||
/** Status codes that prove the upstream answered. 4xx auth-walled counts as up. */
|
||||
const HEALTHY_CODES = new Set([200, 201, 204, 301, 302, 303, 307, 308, 401, 403, 429]);
|
||||
|
||||
const STATE_FILE = process.env.CADDY_UPSTREAMS_STATE_FILE
|
||||
|| path.join(platformPaths.dataDir || path.dirname(platformPaths.configFile || '.'), 'caddy-upstreams.json');
|
||||
|
||||
const SITES_DIR = process.env.CADDY_SITES_DIR || '/etc/caddy/sites';
|
||||
|
||||
class CaddyUpstreamWatcher extends EventEmitter {
|
||||
constructor(opts = {}) {
|
||||
super();
|
||||
this.log = opts.log || console;
|
||||
this.healthChecker = opts.healthChecker || null;
|
||||
/** Map<string, UpstreamState> keyed by host (host[:port]) */
|
||||
this.upstreams = new Map();
|
||||
/** Set<string> hosts the user has muted */
|
||||
this.muted = new Set();
|
||||
/** Set<string> incident IDs currently open — prevents duplicate incidents */
|
||||
this.openIncidents = new Set();
|
||||
this.timer = null;
|
||||
this.checking = false;
|
||||
this.scanTimer = null;
|
||||
this._loadState();
|
||||
}
|
||||
|
||||
/** Begin watching. Idempotent — safe to call twice. */
|
||||
start() {
|
||||
if (this.checking) return;
|
||||
this.checking = true;
|
||||
// Initial scan + probe so the dashboard has data immediately after boot.
|
||||
this.scanSites().catch((e) => this.log.warn('caddy-upstream-watcher', e?.message || String(e)));
|
||||
this.timer = setInterval(() => this._tick().catch(() => {}), PROBE_INTERVAL_MS);
|
||||
// Re-scan sites every 5 min so newly added sites get picked up.
|
||||
this.scanTimer = setInterval(() => this.scanSites().catch(() => {}), 5 * 60 * 1000);
|
||||
this.log.info?.('caddy-upstream-watcher', 'started', {
|
||||
probeIntervalMs: PROBE_INTERVAL_MS,
|
||||
deadAfterMs: DEAD_AFTER_MS,
|
||||
stateFile: STATE_FILE,
|
||||
sitesDir: SITES_DIR
|
||||
}) ?? this.log.info?.('caddy-upstream-watcher', 'started');
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.checking) return;
|
||||
this.checking = false;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.scanTimer) clearInterval(this.scanTimer);
|
||||
this.timer = null;
|
||||
this.scanTimer = null;
|
||||
}
|
||||
|
||||
/** Parse /etc/caddy/sites/* and seed/refresh the upstream map. */
|
||||
async scanSites() {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(SITES_DIR);
|
||||
} catch (e) {
|
||||
// Sites dir might not exist in dev — that's OK, just skip.
|
||||
this.log.warn?.('caddy-upstream-watcher', `cannot read ${SITES_DIR}: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const entry of entries) {
|
||||
// Caddy `import` sites have a wild mix of extensions: `.sami`,
|
||||
// `.caddy`, `.conf` — and ALSO bare hostnames like
|
||||
// `zap.sami-ahmed.net`, `samitest.space`, `blocks.cryptographic-triangles.org`
|
||||
// where the "extension" is `.net`/`.space`/`.org`. Filter out known
|
||||
// non-site junk (readmes, .bak) and accept everything else; the
|
||||
// reverse_proxy parse below is the real validation.
|
||||
if (/^README|\.bak$|\.swp$|^\.|^#/.test(entry)) continue;
|
||||
if (entry === 'Caddyfile' || entry === 'caddyfile') continue;
|
||||
const filePath = path.join(SITES_DIR, entry);
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch (_) { continue; }
|
||||
|
||||
// Cheap pre-check: skip files with no reverse_proxy and no brace block
|
||||
// (README files, .gitignore, etc.). The reverse_proxy regex below is
|
||||
// the authoritative parse, but this avoids regex-scanning every
|
||||
// unrelated file in the directory.
|
||||
if (!/reverse_proxy/i.test(content)) continue;
|
||||
|
||||
// Capture the site block host from the first line: e.g. "arch.sami {"
|
||||
const siteMatch = content.match(/^\s*([a-z0-9._-]+)\s*\{/im);
|
||||
const siteName = siteMatch ? siteMatch[1] : entry.replace(/\.(sami|caddy|conf)$/i, '');
|
||||
|
||||
// Find every reverse_proxy <host[:port]> directive. Match common shapes:
|
||||
// reverse_proxy 100.120.159.34:5000 { ... }
|
||||
// reverse_proxy http://100.120.159.34:5000 { ... }
|
||||
// reverse_proxy 100.120.159.34:5000
|
||||
const re = /reverse_proxy\s+(?:https?:\/\/)?([0-9]{1,3}(?:\.[0-9]{1,3}){3}|[a-z0-9._-]+)(?::(\d+))?/gi;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const host = m[1];
|
||||
let port = m[2];
|
||||
if (!port) {
|
||||
if (m[0].includes('https')) port = '443';
|
||||
else if (m[0].includes('http://')) port = '80';
|
||||
else port = '';
|
||||
}
|
||||
const key = port ? `${host}:${port}` : host;
|
||||
seen.add(key);
|
||||
if (!this.upstreams.has(key)) {
|
||||
this.upstreams.set(key, {
|
||||
host: key,
|
||||
ip: host,
|
||||
port: port || null,
|
||||
site: siteName,
|
||||
siteFile: entry,
|
||||
consecutiveFailures: 0,
|
||||
lastFailureAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
lastCheckedAt: null,
|
||||
status: 'unknown'
|
||||
});
|
||||
} else {
|
||||
// Refresh site name/file in case the file was renamed.
|
||||
const u = this.upstreams.get(key);
|
||||
u.site = siteName;
|
||||
u.siteFile = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop upstreams that disappeared from the Caddyfile (removed/renamed site).
|
||||
for (const key of Array.from(this.upstreams.keys())) {
|
||||
if (!seen.has(key)) this.upstreams.delete(key);
|
||||
}
|
||||
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
/** Single probe tick over every upstream. */
|
||||
async _tick() {
|
||||
const probes = [];
|
||||
for (const u of this.upstreams.values()) {
|
||||
if (this.muted.has(u.host)) continue;
|
||||
probes.push(this._probeOne(u).catch((e) => {
|
||||
this.log.warn?.('caddy-upstream-watcher', `probe failed for ${u.host}: ${e.message}`);
|
||||
}));
|
||||
}
|
||||
await Promise.all(probes);
|
||||
this._saveState();
|
||||
this.emit('tick', this.snapshot());
|
||||
}
|
||||
|
||||
/** Probe a single upstream and update state. */
|
||||
async _probeOne(u) {
|
||||
const result = await this._doProbe(u.ip, u.port);
|
||||
u.lastCheckedAt = new Date().toISOString();
|
||||
|
||||
if (result.healthy) {
|
||||
u.consecutiveFailures = 0;
|
||||
u.lastSuccessAt = u.lastCheckedAt;
|
||||
u.lastError = null;
|
||||
// Resolve open incident if upstream is healthy for RESOLVED_AFTER_MS.
|
||||
this._maybeResolve(u);
|
||||
// Only flip to 'up' if the upstream has been healthy long enough to not
|
||||
// be a flapping signal — short blips are normal and we want the dashboard
|
||||
// to be stable. After one full successful check we mark 'up' but the
|
||||
// incident resolution waits for RESOLVED_AFTER_MS.
|
||||
u.status = 'up';
|
||||
} else {
|
||||
u.consecutiveFailures += 1;
|
||||
u.lastFailureAt = u.lastCheckedAt;
|
||||
u.lastError = result.error || `HTTP ${result.statusCode || 'unknown'}`;
|
||||
// First failure flips status to 'down' immediately for the dashboard, but
|
||||
// we only OPEN an incident after the upstream has been continuously failing
|
||||
// for DEAD_AFTER_MS (5 min by default) so a single transient blip doesn't
|
||||
// page anyone.
|
||||
u.status = 'down';
|
||||
this._maybeOpenIncident(u);
|
||||
}
|
||||
}
|
||||
|
||||
_maybeOpenIncident(u) {
|
||||
if (!this.healthChecker) return;
|
||||
// "failingForMs" = continuous time the upstream has been unhealthy.
|
||||
// Use lastSuccessAt as the anchor — if it was up 7min ago and is still
|
||||
// down now, that's 7 minutes of continuous failure regardless of how many
|
||||
// individual probe failures have piled up in between. Falls back to
|
||||
// consecutiveFailures * interval when there's no success anchor (e.g. we've
|
||||
// never seen the upstream healthy since startup).
|
||||
const lastSuccessMs = u.lastSuccessAt ? new Date(u.lastSuccessAt).getTime() : null;
|
||||
const failingForMs = lastSuccessMs !== null
|
||||
? Math.max(0, Date.now() - lastSuccessMs)
|
||||
: u.consecutiveFailures * PROBE_INTERVAL_MS;
|
||||
if (failingForMs < DEAD_AFTER_MS) return;
|
||||
if (this.openIncidents.has(u.host)) return;
|
||||
|
||||
// Mimic the shape HealthChecker.createIncident expects.
|
||||
try {
|
||||
this.healthChecker.createIncident(u.host, 'caddy-upstream-dead',
|
||||
`Caddy upstream ${u.host} (site ${u.site}) unreachable for ${Math.round(failingForMs / 60000)}m: ${u.lastError || 'no response'}`,
|
||||
{
|
||||
serviceId: u.host,
|
||||
timestamp: u.lastFailureAt,
|
||||
status: 'down',
|
||||
error: u.lastError,
|
||||
details: { site: u.site, siteFile: u.siteFile }
|
||||
}
|
||||
);
|
||||
this.openIncidents.add(u.host);
|
||||
this.emit('upstream-dead', u);
|
||||
this.log.warn?.('caddy-upstream-watcher', `upstream dead: ${u.host} (${u.site})`);
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `incident create failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_maybeResolve(u) {
|
||||
if (!this.healthChecker) return;
|
||||
if (!this.openIncidents.has(u.host)) return;
|
||||
const downSince = u.lastFailureAt ? new Date(u.lastFailureAt).getTime() : 0;
|
||||
const recoveredForMs = downSince ? Date.now() - downSince : 0;
|
||||
if (recoveredForMs < RESOLVED_AFTER_MS) return;
|
||||
try {
|
||||
this.healthChecker.resolveIncident(u.host, 'caddy-upstream-dead', {
|
||||
serviceId: u.host,
|
||||
timestamp: u.lastSuccessAt || new Date().toISOString(),
|
||||
status: 'up'
|
||||
});
|
||||
this.openIncidents.delete(u.host);
|
||||
this.emit('upstream-recovered', u);
|
||||
this.log.info?.('caddy-upstream-watcher', `upstream recovered: ${u.host}`);
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `incident resolve failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_doProbe(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const isHttps = port === '443';
|
||||
const lib = isHttps ? https : http;
|
||||
const opts = {
|
||||
hostname: host,
|
||||
port: port || (isHttps ? 443 : 80),
|
||||
method: 'HEAD',
|
||||
path: '/',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
headers: { 'X-DashCaddy-HealthCheck': '1', 'User-Agent': 'DashCaddy-CaddyUpstreamWatcher/1' },
|
||||
rejectUnauthorized: false
|
||||
};
|
||||
const req = lib.request(opts, (res) => {
|
||||
res.resume();
|
||||
const healthy = HEALTHY_CODES.has(res.statusCode);
|
||||
resolve({ healthy, statusCode: res.statusCode });
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('probe timeout'));
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
resolve({ healthy: false, error: err.message });
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Public snapshot for the API/UI. */
|
||||
snapshot() {
|
||||
const list = [];
|
||||
for (const u of this.upstreams.values()) {
|
||||
const muted = this.muted.has(u.host);
|
||||
// Same anchor as _maybeOpenIncident: time since the last successful
|
||||
// probe. If we've never seen a success, fall back to consecutive
|
||||
// failures × probe interval as a worst-case lower bound.
|
||||
const lastSuccessMs = u.lastSuccessAt ? new Date(u.lastSuccessAt).getTime() : null;
|
||||
let failingFor = 0;
|
||||
if (!muted) {
|
||||
if (lastSuccessMs !== null) {
|
||||
failingFor = Math.max(0, Date.now() - lastSuccessMs);
|
||||
} else if (u.status === 'down') {
|
||||
failingFor = u.consecutiveFailures * PROBE_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
list.push({
|
||||
host: u.host,
|
||||
site: u.site,
|
||||
siteFile: u.siteFile,
|
||||
status: muted ? 'muted' : u.status,
|
||||
consecutiveFailures: u.consecutiveFailures,
|
||||
lastCheckedAt: u.lastCheckedAt,
|
||||
lastSuccessAt: u.lastSuccessAt,
|
||||
lastFailureAt: u.lastFailureAt,
|
||||
lastError: u.lastError,
|
||||
failingForMs: failingFor,
|
||||
muted,
|
||||
dead: !muted && failingFor >= DEAD_AFTER_MS
|
||||
});
|
||||
}
|
||||
// Sort: dead first, then down, then up, then unknown. Within each, by host.
|
||||
list.sort((a, b) => {
|
||||
const order = { dead: 0, down: 1, muted: 2, up: 3, unknown: 4 };
|
||||
const oa = order[a.dead ? 'dead' : a.status] ?? 9;
|
||||
const ob = order[b.dead ? 'dead' : b.status] ?? 9;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.host.localeCompare(b.host);
|
||||
});
|
||||
return {
|
||||
upstreams: list,
|
||||
config: {
|
||||
probeIntervalMs: PROBE_INTERVAL_MS,
|
||||
deadAfterMs: DEAD_AFTER_MS,
|
||||
resolvedAfterMs: RESOLVED_AFTER_MS,
|
||||
sitesDir: SITES_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setMuted(host, muted) {
|
||||
if (muted) {
|
||||
this.muted.add(host);
|
||||
} else {
|
||||
this.muted.delete(host);
|
||||
// Reset failure state on unmute so we don't immediately re-incident a
|
||||
// upstream that just came off mute.
|
||||
const u = this.upstreams.get(host);
|
||||
if (u) {
|
||||
u.consecutiveFailures = 0;
|
||||
u.lastError = null;
|
||||
u.lastFailureAt = null;
|
||||
u.status = 'unknown';
|
||||
}
|
||||
}
|
||||
this._saveState();
|
||||
return { host, muted: !!muted };
|
||||
}
|
||||
|
||||
isMuted(host) { return this.muted.has(host); }
|
||||
|
||||
_loadState() {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_FILE)) return;
|
||||
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
if (Array.isArray(data.muted)) this.muted = new Set(data.muted);
|
||||
// Don't reload upstreams from disk — sites dir is the source of truth.
|
||||
// But preserve last-check state for hosts that still exist.
|
||||
if (data.upstreams && typeof data.upstreams === 'object') {
|
||||
this._restoreUpstreamStates(data.upstreams);
|
||||
}
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `state load failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_restoreUpstreamStates(persisted) {
|
||||
for (const [host, st] of Object.entries(persisted)) {
|
||||
if (this.upstreams.has(host)) continue;
|
||||
this.upstreams.set(host, {
|
||||
host,
|
||||
ip: st.ip || host.split(':')[0],
|
||||
port: st.port || null,
|
||||
site: st.site || '',
|
||||
siteFile: st.siteFile || '',
|
||||
consecutiveFailures: st.consecutiveFailures || 0,
|
||||
lastFailureAt: st.lastFailureAt || null,
|
||||
lastSuccessAt: st.lastSuccessAt || null,
|
||||
lastError: st.lastError || null,
|
||||
lastCheckedAt: st.lastCheckedAt || null,
|
||||
status: 'unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_saveState() {
|
||||
try {
|
||||
const dir = path.dirname(STATE_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const upstreams = {};
|
||||
for (const [k, v] of this.upstreams.entries()) {
|
||||
upstreams[k] = {
|
||||
ip: v.ip,
|
||||
port: v.port,
|
||||
site: v.site,
|
||||
siteFile: v.siteFile,
|
||||
consecutiveFailures: v.consecutiveFailures,
|
||||
lastFailureAt: v.lastFailureAt,
|
||||
lastSuccessAt: v.lastSuccessAt,
|
||||
lastError: v.lastError,
|
||||
lastCheckedAt: v.lastCheckedAt
|
||||
};
|
||||
}
|
||||
const tmp = STATE_FILE + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify({ muted: Array.from(this.muted), upstreams }, null, 2));
|
||||
fs.renameSync(tmp, STATE_FILE);
|
||||
} catch (e) {
|
||||
this.log.warn?.('caddy-upstream-watcher', `state save failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — matches the pattern of health-checker.js so it integrates
|
||||
// without a separate instantiation site.
|
||||
module.exports = new CaddyUpstreamWatcher();
|
||||
module.exports.CaddyUpstreamWatcher = CaddyUpstreamWatcher;
|
||||
@@ -331,7 +331,7 @@ class DiskSpaceMonitor extends EventEmitter {
|
||||
result.error = err.message;
|
||||
result.completedAt = new Date().toISOString();
|
||||
if (this.log) {
|
||||
this.log.error('disk', 'Disk cleanup failed', { error: err.message, level });
|
||||
this.log.error('disk', err, null, { note: 'Disk cleanup failed', level });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ class SSLMonitor extends EventEmitter {
|
||||
try {
|
||||
servicesData = await this.ctx.servicesStateManager.read();
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Failed to read services' });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
@@ -212,13 +212,13 @@ class SSLMonitor extends EventEmitter {
|
||||
|
||||
// Initial check (non-blocking)
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Initial SSL check failed' });
|
||||
});
|
||||
|
||||
// Schedule periodic checks
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Periodic SSL check failed' });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
|
||||
@@ -299,7 +299,7 @@ class SSLMonitor extends EventEmitter {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Periodic SSL check failed' });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ class SSLMonitor extends EventEmitter {
|
||||
validTo: certResult.validTo
|
||||
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
|
||||
this.log.error('ssl-monitor', err, null, { note: 'Failed to send SSL notification' });
|
||||
}
|
||||
}
|
||||
} else if (level === null) {
|
||||
|
||||
@@ -81,7 +81,7 @@ class PluginManager extends EventEmitter {
|
||||
workflowActions: [...this.workflowActions.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.error('plugins', 'Failed to scan plugin directory', { error: err.message });
|
||||
this.log.error('plugins', err, null, { note: 'Failed to scan plugin directory' });
|
||||
this.loaded = true; // Don't crash — just run without plugins
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,9 @@
|
||||
* ./error-logger.js and its ./error.log file have been retired.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { AppError } = require('./errors');
|
||||
const { LIMITS } = require('./constants');
|
||||
const { logError: unifiedLogError, safeErrorMessage } = require('../utils/logging');
|
||||
const { errorResponse } = require('../utils/responses');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(platformPaths.dataDir, 'error.log');
|
||||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||
|
||||
/**
|
||||
* Global error handling middleware
|
||||
@@ -24,11 +18,10 @@ const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||||
function errorMiddleware(err, req, res, next) {
|
||||
// Log all errors with request context (unified, same file the rest of the app uses)
|
||||
unifiedLogError(
|
||||
ERROR_LOG_FILE,
|
||||
MAX_ERROR_LOG_SIZE,
|
||||
req.path,
|
||||
err,
|
||||
{
|
||||
req,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userId: req.user?.id,
|
||||
|
||||
@@ -25,6 +25,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Container not found', 'error.service_not_found': 'Service not found',
|
||||
'error.invalid_input': 'Invalid input', 'error.docker_unreachable': 'Docker daemon is not reachable',
|
||||
'error.disk_full': 'Disk space is critically low',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ON', 'card.status.off': 'OFF', 'card.status.yes': 'YES', 'card.status.no': 'NO', 'card.auth.not_configured': 'Not configured', 'action.open': 'Open', 'action.logs': 'Logs', 'action.settings': 'Settings', 'common.loading': 'Loading…', 'filter.services_placeholder': 'Filter services...', 'filter.all_status': 'All Status', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'All Categories', 'filter.batch_operations': 'Batch Operations',
|
||||
},
|
||||
ar: { // 🇸🇦 العربية
|
||||
'dashboard.title': 'لوحة التحكم', 'dashboard.services': 'الخدمات', 'dashboard.containers': 'الحاويات',
|
||||
@@ -40,6 +42,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'الحاوية غير موجودة', 'error.service_not_found': 'الخدمة غير موجودة',
|
||||
'error.invalid_input': 'إدخال غير صالح', 'error.docker_unreachable': 'لا يمكن الوصول إلى Docker',
|
||||
'error.disk_full': 'مساحة القرص منخفضة بشكل حرج',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'تشغيل', 'card.status.off': 'إيقاف', 'card.status.yes': 'نعم', 'card.status.no': 'لا', 'card.auth.not_configured': 'غير مُهيأ', 'action.open': 'فتح', 'action.logs': 'السجلات', 'action.settings': 'الإعدادات', 'common.loading': 'جار التحميل…', 'filter.services_placeholder': 'تصفية الخدمات...', 'filter.all_status': 'كل الحالات', 'filter.online': 'متصل', 'filter.offline': 'غير متصل', 'filter.all_categories': 'كل الفئات', 'filter.batch_operations': 'عمليات دفعية',
|
||||
},
|
||||
bn: { // 🇧🇩 বাংলা
|
||||
'dashboard.title': 'ড্যাশবোর্ড', 'dashboard.services': 'পরিষেবা', 'dashboard.containers': 'কন্টেইনার',
|
||||
@@ -55,6 +59,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'কন্টেইনার পাওয়া যায়নি', 'error.service_not_found': 'পরিষেবা পাওয়া যায়নি',
|
||||
'error.invalid_input': 'অবৈধ ইনপুট', 'error.docker_unreachable': 'Docker ডেমনে পৌঁছানো যাচ্ছে না',
|
||||
'error.disk_full': 'ডিস্ক স্থান সংকটজনকভাবে কম',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'চালু', 'card.status.off': 'বন্ধ', 'card.status.yes': 'হ্যাঁ', 'card.status.no': 'না', 'card.auth.not_configured': 'কনফিগার করা হয়নি', 'action.open': 'খুলুন', 'action.logs': 'লগ', 'action.settings': 'সেটিংস', 'common.loading': 'লোড হচ্ছে…', 'filter.services_placeholder': 'পরিষেবা ফিল্টার করুন...', 'filter.all_status': 'সব অবস্থা', 'filter.online': 'অনলাইন', 'filter.offline': 'অফলাইন', 'filter.all_categories': 'সব বিভাগ', 'filter.batch_operations': 'ব্যাচ অপারেশন',
|
||||
},
|
||||
cs: { // 🇨🇿 Čeština
|
||||
'dashboard.title': 'Nástěnka', 'dashboard.services': 'Služby', 'dashboard.containers': 'Kontejnery',
|
||||
@@ -70,6 +76,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Kontejner nenalezen', 'error.service_not_found': 'Služba nenalezena',
|
||||
'error.invalid_input': 'Neplatný vstup', 'error.docker_unreachable': 'Docker daemon není dostupný',
|
||||
'error.disk_full': 'Místo na disku je kriticky nízké',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ZAP', 'card.status.off': 'VYP', 'card.status.yes': 'ANO', 'card.status.no': 'NE', 'card.auth.not_configured': 'Nenakonfigurováno', 'action.open': 'Otevřít', 'action.logs': 'Záznamy', 'action.settings': 'Nastavení', 'common.loading': 'Načítání…', 'filter.services_placeholder': 'Filtrovat služby...', 'filter.all_status': 'Všechny stavy', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Všechny kategorie', 'filter.batch_operations': 'Hromadné operace',
|
||||
},
|
||||
da: { // 🇩🇰 Dansk
|
||||
'dashboard.title': 'Instrumentbræt', 'dashboard.services': 'Tjenester', 'dashboard.containers': 'Containere',
|
||||
@@ -85,6 +93,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Container ikke fundet', 'error.service_not_found': 'Tjeneste ikke fundet',
|
||||
'error.invalid_input': 'Ugyldigt input', 'error.docker_unreachable': 'Docker-daemon er ikke tilgængelig',
|
||||
'error.disk_full': 'Diskpladsen er kritisk lav',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'TIL', 'card.status.off': 'FRA', 'card.status.yes': 'JA', 'card.status.no': 'NEJ', 'card.auth.not_configured': 'Ikke konfigureret', 'action.open': 'Åbn', 'action.logs': 'Logfiler', 'action.settings': 'Indstillinger', 'common.loading': 'Indlæser…', 'filter.services_placeholder': 'Filtrer tjenester...', 'filter.all_status': 'Alle statusser', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Alle kategorier', 'filter.batch_operations': 'Batchhandlinger',
|
||||
},
|
||||
de: { // 🇩🇪 Deutsch
|
||||
'dashboard.title': 'Dashboard', 'dashboard.services': 'Dienste', 'dashboard.containers': 'Container',
|
||||
@@ -100,6 +110,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Container nicht gefunden', 'error.service_not_found': 'Dienst nicht gefunden',
|
||||
'error.invalid_input': 'Ungültige Eingabe', 'error.docker_unreachable': 'Docker-Daemon ist nicht erreichbar',
|
||||
'error.disk_full': 'Speicherplatz kritisch niedrig',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'AN', 'card.status.off': 'AUS', 'card.status.yes': 'JA', 'card.status.no': 'NEIN', 'card.auth.not_configured': 'Nicht konfiguriert', 'action.open': 'Öffnen', 'action.logs': 'Protokolle', 'action.settings': 'Einstellungen', 'common.loading': 'Laden…', 'filter.services_placeholder': 'Dienste filtern...', 'filter.all_status': 'Alle Status', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Alle Kategorien', 'filter.batch_operations': 'Stapeloperationen',
|
||||
},
|
||||
el: { // 🇬🇷 Ελληνικά
|
||||
'dashboard.title': 'Πίνακας ελέγχου', 'dashboard.services': 'Υπηρεσίες', 'dashboard.containers': 'Κοντέινερ',
|
||||
@@ -115,6 +127,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Το κοντέινερ δεν βρέθηκε', 'error.service_not_found': 'Η υπηρεσία δεν βρέθηκε',
|
||||
'error.invalid_input': 'Μη έγκυρη είσοδος', 'error.docker_unreachable': 'Ο δαίμονας Docker δεν είναι προσβάσιμος',
|
||||
'error.disk_full': 'Ο χώρος δίσκου είναι κρίσιμα χαμηλός',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ΕΝΕΡΓ', 'card.status.off': 'ΑΝΕΝ', 'card.status.yes': 'ΝΑΙ', 'card.status.no': 'ΟΧΙ', 'card.auth.not_configured': 'Δεν έχει ρυθμιστεί', 'action.open': 'Άνοιγμα', 'action.logs': 'Καταγραφές', 'action.settings': 'Ρυθμίσεις', 'common.loading': 'Φόρτωση…', 'filter.services_placeholder': 'Φιλτράρισμα υπηρεσιών...', 'filter.all_status': 'Όλες οι καταστάσεις', 'filter.online': 'Σε σύνδεση', 'filter.offline': 'Εκτός σύνδεσης', 'filter.all_categories': 'Όλες οι κατηγορίες', 'filter.batch_operations': 'Μαζικές λειτουργίες',
|
||||
},
|
||||
es: { // 🇪🇸 Español
|
||||
'dashboard.title': 'Panel de control', 'dashboard.services': 'Servicios', 'dashboard.containers': 'Contenedores',
|
||||
@@ -130,6 +144,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Contenedor no encontrado', 'error.service_not_found': 'Servicio no encontrado',
|
||||
'error.invalid_input': 'Entrada inválida', 'error.docker_unreachable': 'El demonio de Docker no es accesible',
|
||||
'error.disk_full': 'Espacio en disco críticamente bajo',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ENC', 'card.status.off': 'APAG', 'card.status.yes': 'SÍ', 'card.status.no': 'NO', 'card.auth.not_configured': 'Sin configurar', 'action.open': 'Abrir', 'action.logs': 'Registros', 'action.settings': 'Configuración', 'common.loading': 'Cargando…', 'filter.services_placeholder': 'Filtrar servicios...', 'filter.all_status': 'Todos los estados', 'filter.online': 'En línea', 'filter.offline': 'Sin conexión', 'filter.all_categories': 'Todas las categorías', 'filter.batch_operations': 'Operaciones por lotes',
|
||||
},
|
||||
fa: { // 🇮🇷 فارسی
|
||||
'dashboard.title': 'داشبورد', 'dashboard.services': 'سرویسها', 'dashboard.containers': 'کانتینرها',
|
||||
@@ -145,6 +161,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'کانتینر یافت نشد', 'error.service_not_found': 'سرویس یافت نشد',
|
||||
'error.invalid_input': 'ورودی نامعتبر', 'error.docker_unreachable': 'دسترسی به Docker daemon ممکن نیست',
|
||||
'error.disk_full': 'فضای دیسک بهطور بحرانی کم است',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'روشن', 'card.status.off': 'خاموش', 'card.status.yes': 'بله', 'card.status.no': 'خیر', 'card.auth.not_configured': 'پیکربندی نشده', 'action.open': 'باز کردن', 'action.logs': 'گزارشها', 'action.settings': 'تنظیمات', 'common.loading': 'در حال بارگذاری…', 'filter.services_placeholder': 'فیلتر خدمات...', 'filter.all_status': 'همه وضعیتها', 'filter.online': 'آنلاین', 'filter.offline': 'آفلاین', 'filter.all_categories': 'همه دستهها', 'filter.batch_operations': 'عملیات دستهای',
|
||||
},
|
||||
fi: { // 🇫🇮 Suomi
|
||||
'dashboard.title': 'Ohjauspaneeli', 'dashboard.services': 'Palvelut', 'dashboard.containers': 'Kontainerit',
|
||||
@@ -160,6 +178,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Kontaineria ei löytynyt', 'error.service_not_found': 'Palvelua ei löytynyt',
|
||||
'error.invalid_input': 'Virheellinen syöte', 'error.docker_unreachable': 'Docker-daemoniin ei saada yhteyttä',
|
||||
'error.disk_full': 'Levytila on kriittisesti vähissä',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'PÄÄLLÄ', 'card.status.off': 'POIS', 'card.status.yes': 'KYLLÄ', 'card.status.no': 'EI', 'card.auth.not_configured': 'Ei määritetty', 'action.open': 'Avaa', 'action.logs': 'Lokit', 'action.settings': 'Asetukset', 'common.loading': 'Ladataan…', 'filter.services_placeholder': 'Suodata palveluita...', 'filter.all_status': 'Kaikki tilat', 'filter.online': 'Paikallaan', 'filter.offline': 'Poissa', 'filter.all_categories': 'Kaikki luokat', 'filter.batch_operations': 'Erätoiminnot',
|
||||
},
|
||||
fr: { // 🇫🇷 Français
|
||||
'dashboard.title': 'Tableau de bord', 'dashboard.services': 'Services', 'dashboard.containers': 'Conteneurs',
|
||||
@@ -175,6 +195,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Conteneur introuvable', 'error.service_not_found': 'Service introuvable',
|
||||
'error.invalid_input': 'Entrée invalide', 'error.docker_unreachable': 'Le démon Docker est injoignable',
|
||||
'error.disk_full': 'Espace disque critique',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ALLUMÉ', 'card.status.off': 'ÉTEINT', 'card.status.yes': 'OUI', 'card.status.no': 'NON', 'card.auth.not_configured': 'Non configuré', 'action.open': 'Ouvrir', 'action.logs': 'Journaux', 'action.settings': 'Paramètres', 'common.loading': 'Chargement…', 'filter.services_placeholder': 'Filtrer les services...', 'filter.all_status': 'Tous les statuts', 'filter.online': 'En ligne', 'filter.offline': 'Hors ligne', 'filter.all_categories': 'Toutes les catégories', 'filter.batch_operations': 'Opérations par lot',
|
||||
},
|
||||
hi: { // 🇮🇳 हिन्दी
|
||||
'dashboard.title': 'डैशबोर्ड', 'dashboard.services': 'सेवाएं', 'dashboard.containers': 'कंटेनर',
|
||||
@@ -190,6 +212,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'कंटेनर नहीं मिला', 'error.service_not_found': 'सेवा नहीं मिली',
|
||||
'error.invalid_input': 'अमान्य इनपुट', 'error.docker_unreachable': 'Docker डेमन तक नहीं पहुंच सकते',
|
||||
'error.disk_full': 'डिस्क स्थान गंभीर रूप से कम है',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'चालू', 'card.status.off': 'बंद', 'card.status.yes': 'हाँ', 'card.status.no': 'नहीं', 'card.auth.not_configured': 'कॉन्फ़िगर नहीं किया गया', 'action.open': 'खोलें', 'action.logs': 'लॉग', 'action.settings': 'सेटिंग्स', 'common.loading': 'लोड हो रहा है…', 'filter.services_placeholder': 'सेवाएं फ़िल्टर करें...', 'filter.all_status': 'सभी स्थिति', 'filter.online': 'ऑनलाइन', 'filter.offline': 'ऑफ़लाइन', 'filter.all_categories': 'सभी श्रेणियाँ', 'filter.batch_operations': 'बैच संचालन',
|
||||
},
|
||||
hu: { // 🇭🇺 Magyar
|
||||
'dashboard.title': 'Vezérlőpult', 'dashboard.services': 'Szolgáltatások', 'dashboard.containers': 'Konténerek',
|
||||
@@ -205,6 +229,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'A konténer nem található', 'error.service_not_found': 'A szolgáltatás nem található',
|
||||
'error.invalid_input': 'Érvénytelen bemenet', 'error.docker_unreachable': 'A Docker démon nem érhető el',
|
||||
'error.disk_full': 'A lemezterület kritikusan alacsony',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'BE', 'card.status.off': 'KI', 'card.status.yes': 'IGEN', 'card.status.no': 'NEM', 'card.auth.not_configured': 'Nincs beállítva', 'action.open': 'Megnyitás', 'action.logs': 'Naplók', 'action.settings': 'Beállítások', 'common.loading': 'Betöltés…', 'filter.services_placeholder': 'Szolgáltatások szűrése...', 'filter.all_status': 'Összes állapot', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Összes kategória', 'filter.batch_operations': 'Tömeges műveletek',
|
||||
},
|
||||
id: { // 🇮🇩 Indonesia
|
||||
'dashboard.title': 'Dasbor', 'dashboard.services': 'Layanan', 'dashboard.containers': 'Kontainer',
|
||||
@@ -220,6 +246,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Kontainer tidak ditemukan', 'error.service_not_found': 'Layanan tidak ditemukan',
|
||||
'error.invalid_input': 'Input tidak valid', 'error.docker_unreachable': 'Daemon Docker tidak dapat dijangkau',
|
||||
'error.disk_full': 'Ruang disk sangat rendah',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'HIDUP', 'card.status.off': 'MATI', 'card.status.yes': 'YA', 'card.status.no': 'TIDAK', 'card.auth.not_configured': 'Belum dikonfigurasi', 'action.open': 'Buka', 'action.logs': 'Log', 'action.settings': 'Pengaturan', 'common.loading': 'Memuat…', 'filter.services_placeholder': 'Filter layanan...', 'filter.all_status': 'Semua Status', 'filter.online': 'Daring', 'filter.offline': 'Luring', 'filter.all_categories': 'Semua Kategori', 'filter.batch_operations': 'Operasi Batch',
|
||||
},
|
||||
it: { // 🇮🇹 Italiano
|
||||
'dashboard.title': 'Cruscotto', 'dashboard.services': 'Servizi', 'dashboard.containers': 'Contenitori',
|
||||
@@ -235,6 +263,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Contenitore non trovato', 'error.service_not_found': 'Servizio non trovato',
|
||||
'error.invalid_input': 'Input non valido', 'error.docker_unreachable': 'Il daemon Docker non è raggiungibile',
|
||||
'error.disk_full': 'Spazio su disco criticamente basso',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ON', 'card.status.off': 'OFF', 'card.status.yes': 'SÌ', 'card.status.no': 'NO', 'card.auth.not_configured': 'Non configurato', 'action.open': 'Apri', 'action.logs': 'Log', 'action.settings': 'Impostazioni', 'common.loading': 'Caricamento…', 'filter.services_placeholder': 'Filtra servizi...', 'filter.all_status': 'Tutti gli stati', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Tutte le categorie', 'filter.batch_operations': 'Operazioni batch',
|
||||
},
|
||||
ja: { // 🇯🇵 日本語
|
||||
'dashboard.title': 'ダッシュボード', 'dashboard.services': 'サービス', 'dashboard.containers': 'コンテナ',
|
||||
@@ -250,6 +280,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'コンテナが見つかりません', 'error.service_not_found': 'サービスが見つかりません',
|
||||
'error.invalid_input': '無効な入力', 'error.docker_unreachable': 'Dockerデーモンに接続できません',
|
||||
'error.disk_full': 'ディスク容量が致命的に不足しています',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'オン', 'card.status.off': 'オフ', 'card.status.yes': 'はい', 'card.status.no': 'いいえ', 'card.auth.not_configured': '未設定', 'action.open': '開く', 'action.logs': 'ログ', 'action.settings': '設定', 'common.loading': '読み込み中…', 'filter.services_placeholder': 'サービスを絞り込む...', 'filter.all_status': 'すべてのステータス', 'filter.online': 'オンライン', 'filter.offline': 'オフライン', 'filter.all_categories': 'すべてのカテゴリ', 'filter.batch_operations': '一括操作',
|
||||
},
|
||||
ko: { // 🇰🇷 한국어
|
||||
'dashboard.title': '대시보드', 'dashboard.services': '서비스', 'dashboard.containers': '컨테이너',
|
||||
@@ -265,6 +297,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': '컨테이너를 찾을 수 없습니다', 'error.service_not_found': '서비스를 찾을 수 없습니다',
|
||||
'error.invalid_input': '잘못된 입력', 'error.docker_unreachable': 'Docker 데몬에 연결할 수 없습니다',
|
||||
'error.disk_full': '디스크 공간이 심각하게 부족합니다',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': '켜짐', 'card.status.off': '꺼짐', 'card.status.yes': '예', 'card.status.no': '아니오', 'card.auth.not_configured': '설정되지 않음', 'action.open': '열기', 'action.logs': '로그', 'action.settings': '설정', 'common.loading': '로딩 중…', 'filter.services_placeholder': '서비스 필터...', 'filter.all_status': '모든 상태', 'filter.online': '온라인', 'filter.offline': '오프라인', 'filter.all_categories': '모든 카테고리', 'filter.batch_operations': '일괄 작업',
|
||||
},
|
||||
ms: { // 🇲🇾 Melayu
|
||||
'dashboard.title': 'Papan pemuka', 'dashboard.services': 'Perkhidmatan', 'dashboard.containers': 'Bekas',
|
||||
@@ -280,6 +314,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Bekas tidak dijumpai', 'error.service_not_found': 'Perkhidmatan tidak dijumpai',
|
||||
'error.invalid_input': 'Input tidak sah', 'error.docker_unreachable': 'Docker daemon tidak dapat dijangkau',
|
||||
'error.disk_full': 'Ruang cakera sangat kritikal',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'HIDUP', 'card.status.off': 'MATI', 'card.status.yes': 'YA', 'card.status.no': 'TIDAK', 'card.auth.not_configured': 'Tidak dikonfigurasikan', 'action.open': 'Buka', 'action.logs': 'Log', 'action.settings': 'Tetapan', 'common.loading': 'Memuatkan…', 'filter.services_placeholder': 'Tapis perkhidmatan...', 'filter.all_status': 'Semua Status', 'filter.online': 'Dalam talian', 'filter.offline': 'Luar talian', 'filter.all_categories': 'Semua Kategori', 'filter.batch_operations': 'Operasi Kelompok',
|
||||
},
|
||||
nl: { // 🇳🇱 Nederlands
|
||||
'dashboard.title': 'Dashboard', 'dashboard.services': 'Diensten', 'dashboard.containers': 'Containers',
|
||||
@@ -295,6 +331,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Container niet gevonden', 'error.service_not_found': 'Dienst niet gevonden',
|
||||
'error.invalid_input': 'Ongeldige invoer', 'error.docker_unreachable': 'Docker-daemon is niet bereikbaar',
|
||||
'error.disk_full': 'Schijfruimte kritiek laag',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'AAN', 'card.status.off': 'UIT', 'card.status.yes': 'JA', 'card.status.no': 'NEE', 'card.auth.not_configured': 'Niet geconfigureerd', 'action.open': 'Openen', 'action.logs': 'Logboeken', 'action.settings': 'Instellingen', 'common.loading': 'Laden…', 'filter.services_placeholder': 'Services filteren...', 'filter.all_status': 'Alle statussen', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Alle categorieën', 'filter.batch_operations': 'Batchbewerkingen',
|
||||
},
|
||||
no: { // 🇳🇴 Norsk
|
||||
'dashboard.title': 'Kontrollpanel', 'dashboard.services': 'Tjenester', 'dashboard.containers': 'Beholdere',
|
||||
@@ -310,6 +348,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Beholder ikke funnet', 'error.service_not_found': 'Tjeneste ikke funnet',
|
||||
'error.invalid_input': 'Ugyldig inndata', 'error.docker_unreachable': 'Docker-daemon er ikke tilgjengelig',
|
||||
'error.disk_full': 'Diskplassen er kritisk lav',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'PÅ', 'card.status.off': 'AV', 'card.status.yes': 'JA', 'card.status.no': 'NEI', 'card.auth.not_configured': 'Ikke konfigurert', 'action.open': 'Åpne', 'action.logs': 'Logger', 'action.settings': 'Innstillinger', 'common.loading': 'Laster…', 'filter.services_placeholder': 'Filtrer tjenester...', 'filter.all_status': 'Alle statuser', 'filter.online': 'På nett', 'filter.offline': 'Frakoblet', 'filter.all_categories': 'Alle kategorier', 'filter.batch_operations': 'Masseoperasjoner',
|
||||
},
|
||||
pl: { // 🇵🇱 Polski
|
||||
'dashboard.title': 'Panel', 'dashboard.services': 'Usługi', 'dashboard.containers': 'Kontenery',
|
||||
@@ -325,6 +365,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Nie znaleziono kontenera', 'error.service_not_found': 'Nie znaleziono usługi',
|
||||
'error.invalid_input': 'Nieprawidłowe dane wejściowe', 'error.docker_unreachable': 'Daemon Docker jest niedostępny',
|
||||
'error.disk_full': 'Krytycznie mało miejsca na dysku',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'WŁ', 'card.status.off': 'WYŁ', 'card.status.yes': 'TAK', 'card.status.no': 'NIE', 'card.auth.not_configured': 'Nie skonfigurowano', 'action.open': 'Otwórz', 'action.logs': 'Dzienniki', 'action.settings': 'Ustawienia', 'common.loading': 'Ładowanie…', 'filter.services_placeholder': 'Filtruj usługi...', 'filter.all_status': 'Wszystkie statusy', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Wszystkie kategorie', 'filter.batch_operations': 'Operacje wsadowe',
|
||||
},
|
||||
pt: { // 🇵🇹 Português
|
||||
'dashboard.title': 'Painel', 'dashboard.services': 'Serviços', 'dashboard.containers': 'Contêineres',
|
||||
@@ -340,6 +382,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Contêiner não encontrado', 'error.service_not_found': 'Serviço não encontrado',
|
||||
'error.invalid_input': 'Entrada inválida', 'error.docker_unreachable': 'Daemon do Docker inacessível',
|
||||
'error.disk_full': 'Espaço em disco criticamente baixo',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'LIG', 'card.status.off': 'DESL', 'card.status.yes': 'SIM', 'card.status.no': 'NÃO', 'card.auth.not_configured': 'Não configurado', 'action.open': 'Abrir', 'action.logs': 'Registros', 'action.settings': 'Configurações', 'common.loading': 'Carregando…', 'filter.services_placeholder': 'Filtrar serviços...', 'filter.all_status': 'Todos os status', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Todas as categorias', 'filter.batch_operations': 'Operações em lote',
|
||||
},
|
||||
ro: { // 🇷🇴 Română
|
||||
'dashboard.title': 'Tablou de bord', 'dashboard.services': 'Servicii', 'dashboard.containers': 'Containere',
|
||||
@@ -355,6 +399,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Container negăsit', 'error.service_not_found': 'Serviciu negăsit',
|
||||
'error.invalid_input': 'Intrare invalidă', 'error.docker_unreachable': 'Daemonul Docker nu poate fi contactat',
|
||||
'error.disk_full': 'Spațiul pe disc este critic de scăzut',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'PORNIT', 'card.status.off': 'OPRIT', 'card.status.yes': 'DA', 'card.status.no': 'NU', 'card.auth.not_configured': 'Neconfigurat', 'action.open': 'Deschide', 'action.logs': 'Jurnale', 'action.settings': 'Setări', 'common.loading': 'Se încarcă…', 'filter.services_placeholder': 'Filtrează serviciile...', 'filter.all_status': 'Toate statusurile', 'filter.online': 'Online', 'filter.offline': 'Offline', 'filter.all_categories': 'Toate categoriile', 'filter.batch_operations': 'Operațiuni lot',
|
||||
},
|
||||
ru: { // 🇷🇺 Русский
|
||||
'dashboard.title': 'Панель управления', 'dashboard.services': 'Сервисы', 'dashboard.containers': 'Контейнеры',
|
||||
@@ -370,6 +416,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Контейнер не найден', 'error.service_not_found': 'Сервис не найден',
|
||||
'error.invalid_input': 'Неверный ввод', 'error.docker_unreachable': 'Docker недоступен',
|
||||
'error.disk_full': 'Критически мало места на диске',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'ВКЛ', 'card.status.off': 'ВЫКЛ', 'card.status.yes': 'ДА', 'card.status.no': 'НЕТ', 'card.auth.not_configured': 'Не настроено', 'action.open': 'Открыть', 'action.logs': 'Журналы', 'action.settings': 'Настройки', 'common.loading': 'Загрузка…', 'filter.services_placeholder': 'Фильтр сервисов...', 'filter.all_status': 'Все статусы', 'filter.online': 'Онлайн', 'filter.offline': 'Офлайн', 'filter.all_categories': 'Все категории', 'filter.batch_operations': 'Пакетные операции',
|
||||
},
|
||||
sv: { // 🇸🇪 Svenska
|
||||
'dashboard.title': 'Instrumentpanel', 'dashboard.services': 'Tjänster', 'dashboard.containers': 'Behållare',
|
||||
@@ -385,6 +433,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Behållare hittades inte', 'error.service_not_found': 'Tjänst hittades inte',
|
||||
'error.invalid_input': 'Ogiltig inmatning', 'error.docker_unreachable': 'Docker-daemon kan inte nås',
|
||||
'error.disk_full': 'Diskutrymmet är kritiskt lågt',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'PÅ', 'card.status.off': 'AV', 'card.status.yes': 'JA', 'card.status.no': 'NEJ', 'card.auth.not_configured': 'Inte konfigurerad', 'action.open': 'Öppna', 'action.logs': 'Loggar', 'action.settings': 'Inställningar', 'common.loading': 'Laddar…', 'filter.services_placeholder': 'Filtrera tjänster...', 'filter.all_status': 'Alla statusar', 'filter.online': 'Uppkopplad', 'filter.offline': 'Nerkopplad', 'filter.all_categories': 'Alla kategorier', 'filter.batch_operations': 'Batchåtgärder',
|
||||
},
|
||||
th: { // 🇹🇭 ไทย
|
||||
'dashboard.title': 'แดชบอร์ด', 'dashboard.services': 'บริการ', 'dashboard.containers': 'คอนเทนเนอร์',
|
||||
@@ -400,6 +450,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'ไม่พบคอนเทนเนอร์', 'error.service_not_found': 'ไม่พบบริการ',
|
||||
'error.invalid_input': 'อินพุตไม่ถูกต้อง', 'error.docker_unreachable': 'ไม่สามารถเข้าถึง Docker daemon ได้',
|
||||
'error.disk_full': 'พื้นที่ดิสก์เหลือน้อยวิกฤต',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'เปิด', 'card.status.off': 'ปิด', 'card.status.yes': 'ใช่', 'card.status.no': 'ไม่', 'card.auth.not_configured': 'ยังไม่ได้กำหนดค่า', 'action.open': 'เปิด', 'action.logs': 'บันทึก', 'action.settings': 'การตั้งค่า', 'common.loading': 'กำลังโหลด…', 'filter.services_placeholder': 'กรองบริการ...', 'filter.all_status': 'สถานะทั้งหมด', 'filter.online': 'ออนไลน์', 'filter.offline': 'ออฟไลน์', 'filter.all_categories': 'หมวดหมู่ทั้งหมด', 'filter.batch_operations': 'การดำเนินการแบบกลุ่ม',
|
||||
},
|
||||
tr: { // 🇹🇷 Türkçe
|
||||
'dashboard.title': 'Kontrol Paneli', 'dashboard.services': 'Hizmetler', 'dashboard.containers': 'Konteynerler',
|
||||
@@ -415,6 +467,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Konteyner bulunamadı', 'error.service_not_found': 'Hizmet bulunamadı',
|
||||
'error.invalid_input': 'Geçersiz giriş', 'error.docker_unreachable': 'Docker daemonuna ulaşılamıyor',
|
||||
'error.disk_full': 'Disk alanı kritik düzeyde düşük',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'AÇIK', 'card.status.off': 'KAPALI', 'card.status.yes': 'EVET', 'card.status.no': 'HAYIR', 'card.auth.not_configured': 'Yapılandırılmadı', 'action.open': 'Aç', 'action.logs': 'Günlükler', 'action.settings': 'Ayarlar', 'common.loading': 'Yükleniyor…', 'filter.services_placeholder': 'Hizmetleri filtrele...', 'filter.all_status': 'Tüm Durumlar', 'filter.online': 'Çevrimiçi', 'filter.offline': 'Çevrimdışı', 'filter.all_categories': 'Tüm Kategoriler', 'filter.batch_operations': 'Toplu İşlemler',
|
||||
},
|
||||
uk: { // 🇺🇦 Українська
|
||||
'dashboard.title': 'Панель керування', 'dashboard.services': 'Сервіси', 'dashboard.containers': 'Контейнери',
|
||||
@@ -430,6 +484,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Контейнер не знайдено', 'error.service_not_found': 'Сервіс не знайдено',
|
||||
'error.invalid_input': 'Невірне введення', 'error.docker_unreachable': 'Docker недоступний',
|
||||
'error.disk_full': 'Критично мало місця на диску',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'УВІМК', 'card.status.off': 'ВИМК', 'card.status.yes': 'ТАК', 'card.status.no': 'НІ', 'card.auth.not_configured': 'Не налаштовано', 'action.open': 'Відкрити', 'action.logs': 'Журнали', 'action.settings': 'Налаштування', 'common.loading': 'Завантаження…', 'filter.services_placeholder': 'Фільтр сервісів...', 'filter.all_status': 'Усі статуси', 'filter.online': 'Онлайн', 'filter.offline': 'Офлайн', 'filter.all_categories': 'Усі категорії', 'filter.batch_operations': 'Пакетні операції',
|
||||
},
|
||||
ur: { // 🇵🇰 اردو
|
||||
'dashboard.title': 'ڈیش بورڈ', 'dashboard.services': 'خدمات', 'dashboard.containers': 'کنٹینرز',
|
||||
@@ -445,6 +501,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'کنٹینر نہیں ملا', 'error.service_not_found': 'سروس نہیں ملی',
|
||||
'error.invalid_input': 'غلط ان پٹ', 'error.docker_unreachable': 'Docker ڈیمن تک رسائی نہیں',
|
||||
'error.disk_full': 'ڈسک کی جگہ نہایت کم ہے',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'چالو', 'card.status.off': 'بند', 'card.status.yes': 'ہاں', 'card.status.no': 'نہیں', 'card.auth.not_configured': 'ترتیب نہیں دیا گیا', 'action.open': 'کھولیں', 'action.logs': 'لاگز', 'action.settings': 'ترتیبات', 'common.loading': 'لوڈ ہو رہا ہے…', 'filter.services_placeholder': 'خدمات فلٹر کریں...', 'filter.all_status': 'تمام صورتحال', 'filter.online': 'آن لائن', 'filter.offline': 'آف لائن', 'filter.all_categories': 'تمام اقسام', 'filter.batch_operations': 'بیچ آپریشنز',
|
||||
},
|
||||
vi: { // 🇻🇳 Tiếng Việt
|
||||
'dashboard.title': 'Bảng điều khiển', 'dashboard.services': 'Dịch vụ', 'dashboard.containers': 'Bộ chứa',
|
||||
@@ -460,6 +518,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': 'Không tìm thấy bộ chứa', 'error.service_not_found': 'Không tìm thấy dịch vụ',
|
||||
'error.invalid_input': 'Đầu vào không hợp lệ', 'error.docker_unreachable': 'Không thể kết nối với Docker daemon',
|
||||
'error.disk_full': 'Không gian đĩa cực kỳ thấp',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': 'BẬT', 'card.status.off': 'TẮT', 'card.status.yes': 'CÓ', 'card.status.no': 'KHÔNG', 'card.auth.not_configured': 'Chưa cấu hình', 'action.open': 'Mở', 'action.logs': 'Nhật ký', 'action.settings': 'Cài đặt', 'common.loading': 'Đang tải…', 'filter.services_placeholder': 'Lọc dịch vụ...', 'filter.all_status': 'Tất cả trạng thái', 'filter.online': 'Trực tuyến', 'filter.offline': 'Ngoại tuyến', 'filter.all_categories': 'Tất cả danh mục', 'filter.batch_operations': 'Thao tác hàng loạt',
|
||||
},
|
||||
zh: { // 🇨🇳 中文
|
||||
'dashboard.title': '仪表盘', 'dashboard.services': '服务', 'dashboard.containers': '容器',
|
||||
@@ -475,6 +535,8 @@ const TRANSLATIONS = {
|
||||
'error.container_not_found': '未找到容器', 'error.service_not_found': '未找到服务',
|
||||
'error.invalid_input': '输入无效', 'error.docker_unreachable': '无法连接 Docker 守护进程',
|
||||
'error.disk_full': '磁盘空间严重不足',
|
||||
'card.internet': 'Internet', 'card.auth': 'Auth', 'card.tailscale': 'Tailscale', 'card.dashca': 'DashCA',
|
||||
'card.status.on': '开启', 'card.status.off': '关闭', 'card.status.yes': '是', 'card.status.no': '否', 'card.auth.not_configured': '未配置', 'action.open': '打开', 'action.logs': '日志', 'action.settings': '设置', 'common.loading': '加载中…', 'filter.services_placeholder': '筛选服务...', 'filter.all_status': '所有状态', 'filter.online': '在线', 'filter.offline': '离线', 'filter.all_categories': '所有类别', 'filter.batch_operations': '批量操作',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -529,10 +591,40 @@ function isSupported(lang) { return SUPPORTED_LANGUAGES.indexOf(lang) >= 0; }
|
||||
function detectLanguage(acceptLanguage) {
|
||||
if (!acceptLanguage) return DEFAULT_LANGUAGE;
|
||||
var parts = acceptLanguage.split(',');
|
||||
var entries = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var code = parts[i].trim().split(';')[0].split('-')[0].toLowerCase();
|
||||
if (isSupported(code)) return code;
|
||||
var seg = parts[i].trim();
|
||||
if (!seg) continue;
|
||||
var bits = seg.split(';');
|
||||
var code = bits[0].split('-')[0].trim().toLowerCase();
|
||||
if (!code) continue;
|
||||
var q = 1.0;
|
||||
for (var j = 1; j < bits.length; j++) {
|
||||
var kv = bits[j].trim().split('=');
|
||||
if (kv.length === 2 && kv[0].trim().toLowerCase() === 'q') {
|
||||
var qStr = kv[1].trim();
|
||||
// RFC 7231 §5.3.1: qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3"0" ] )
|
||||
// Match the strict grammar; values that do not conform are treated as
|
||||
// "no q-value specified" and fall back to q=1.0, the HTTP default.
|
||||
var qMatch = qStr.match(/^(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/);
|
||||
if (qMatch) {
|
||||
q = parseFloat(qMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.push({ code: code, q: q, order: i });
|
||||
}
|
||||
entries.sort(function (a, b) {
|
||||
if (b.q !== a.q) return b.q - a.q;
|
||||
return a.order - b.order;
|
||||
});
|
||||
for (var k = 0; k < entries.length; k++) {
|
||||
if (entries[k].q === 0) continue;
|
||||
if (isSupported(entries[k].code)) return entries[k].code;
|
||||
}
|
||||
// Intentional design policy: when every supported entry was explicitly
|
||||
// refused with q=0 (or no supported language was offered), fall back to the
|
||||
// server default (DEFAULT_LANGUAGE) rather than honoring the refusal.
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana
|
||||
log.info('health', 'Health checker synced', { added, updated, removed });
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('health', 'Error syncing health checker', { error: error.message });
|
||||
log.error('health', error, null, { note: 'Error syncing health checker' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,14 @@ function safeErrorMessage(error) {
|
||||
// Supports: logError(context, error, extra) → existing route call pattern
|
||||
|
||||
async function logErrorWrapper(ctx, err, extra) {
|
||||
// Guard against legacy call shapes that used to corrupt error.log:
|
||||
// the old 5-arg form logError(file, maxSize, path, err, meta) made ctx
|
||||
// a file path and turned maxSize (a number) into the "error". Detect and
|
||||
// normalize so the real error always reaches error.log.
|
||||
if (typeof ctx === 'string' && /^\/.*\.(log|json)$/.test(ctx) && typeof err === 'number') {
|
||||
// Legacy shape: (file, size, reqPath, error, meta) → shift args.
|
||||
[ctx, err, extra] = [arguments[2], arguments[3], { ...arguments[4], req: undefined }];
|
||||
}
|
||||
const req = extra?.req;
|
||||
const payload = extra ? { ...extra } : {};
|
||||
if (payload.req) delete payload.req;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/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"
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,143 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,682 @@
|
||||
/* 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; }
|
||||
@@ -0,0 +1,474 @@
|
||||
// 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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# /opt/dashcaddy/lock-caddyfile.sh — re-apply immutable flag without breaking the container.
|
||||
# The DashCaddy container reads /etc/caddy/Caddyfile as a bind mount. chattr +i
|
||||
# propagates into the container and breaks startup validation. We apply chattr
|
||||
# +i ONLY when the container is stopped, then unlock before start.sh runs.
|
||||
#
|
||||
# SamiPanel is fully purged from this host (cron removed, binaries gone,
|
||||
# systemd unit masked to /dev/null). The structural protection does not
|
||||
# depend on the immutable flag; this is defense in depth.
|
||||
|
||||
set -e
|
||||
ACTION="${1:-lock}"
|
||||
|
||||
case "$ACTION" in
|
||||
unlock)
|
||||
chattr -i /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
echo "Caddyfile unlocked for container start"
|
||||
;;
|
||||
lock)
|
||||
# Don't lock if container is running — the bind mount would re-introduce
|
||||
# the readonly/immutable state inside the container.
|
||||
if docker ps --filter name=dashcaddy-api --format '{{.Names}}' | grep -q dashcaddy-api; then
|
||||
echo "DashCaddy container is running — leaving Caddyfile mutable for the bind mount"
|
||||
else
|
||||
chattr +i /etc/caddy/Caddyfile
|
||||
echo "Caddyfile locked (immutable)"
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
lsattr /etc/caddy/Caddyfile | head -1
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {lock|unlock|status}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
#!/bin/bash
|
||||
# /usr/local/bin/dashcaddy-watchdog — self-healing guard for DashCaddy on DNS2.
|
||||
#
|
||||
# Heals four failure classes that have actually knocked DashCaddy down:
|
||||
# 1. Container down/unhealthy/removed -> docker start / bash start.sh
|
||||
# 2. Rogue host node process stealing :3001 -> kill it, restart container
|
||||
# 3. Caddyfile wiped by a foreign generator -> restore known-good, restart caddy
|
||||
# 4. Caddy down or not serving -> restart caddy
|
||||
#
|
||||
# Runs from dashcaddy-watchdog.service (systemd timer, every 30s) or manually.
|
||||
# Alerts go to Telegram (Sami) on every corrective action; rate-limited per
|
||||
# action class (default 15 min) to avoid spam during flapping. All state in
|
||||
# /var/lib/dashcaddy-watchdog/ so restarts of the watchdog never flap.
|
||||
#
|
||||
# Exit codes: 0 = healthy or healed cleanly. Healing never exits non-zero —
|
||||
# a permanently-failed unit would pollute systemctl --failed monitoring.
|
||||
|
||||
set -u
|
||||
|
||||
CONTAINER="dashcaddy-api"
|
||||
START_SH="/opt/dashcaddy/start.sh"
|
||||
CADDYFILE="/etc/caddy/Caddyfile"
|
||||
KNOWN_GOOD="/var/lib/dashcaddy-watchdog/known-good-Caddyfile"
|
||||
STATE_DIR="/var/lib/dashcaddy-watchdog"
|
||||
LOG="/var/log/dashcaddy-watchdog.log"
|
||||
ALERT_COOLDOWN=$((15 * 60)) # seconds between alerts of the same class
|
||||
LOG_MAX=1048576 # 1 MiB
|
||||
# Caddyfile integrity gates (adversarial review: markers alone can be
|
||||
# satisfied by a damaged file). Size floor excludes the 4952-byte foreign-
|
||||
# generator file from the 2026-08-13 incident; service-block floor excludes
|
||||
# marker-duplication damage.
|
||||
CADDYFILE_MIN_BYTES=10000
|
||||
CADDYFILE_MIN_SITES=10
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
touch "$LOG"
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG"; }
|
||||
|
||||
# --- Telegram alerting (best-effort; a notify failure never blocks healing) ---
|
||||
notify() { # notify <class> <message>
|
||||
local class="$1" msg="$2"
|
||||
local now last elapsed
|
||||
now=$(date +%s)
|
||||
local stamp="$STATE_DIR/alert-$class"
|
||||
if [ -f "$stamp" ]; then
|
||||
last=$(cat "$stamp" 2>/dev/null | tr -cd '0-9')
|
||||
last="${last:-0}"
|
||||
elapsed=$(( now - last ))
|
||||
if [ "$elapsed" -lt "$ALERT_COOLDOWN" ]; then
|
||||
log "ALERT-SUPPRESSED class=$class (${elapsed}s < ${ALERT_COOLDOWN}s)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
local token chat sent
|
||||
token=$(grep -E '^HERMES_ENV_TELEGRAM_BOT_TOKEN=' /root/.hermes/.env | head -1 | cut -d= -f2-)
|
||||
chat="637130179"
|
||||
if [ -z "$token" ]; then
|
||||
log "ALERT-NO-TOKEN class=$class msg=$msg"
|
||||
return 0
|
||||
fi
|
||||
sent=$(curl -s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage" \
|
||||
-d chat_id="$chat" -d text="🛡️ DashCaddy watchdog (DNS2): $msg" \
|
||||
2>/dev/null | grep -c '"ok":true')
|
||||
if [ "${sent:-0}" -ge 1 ]; then
|
||||
date +%s > "$stamp"
|
||||
log "ALERT-SENT class=$class msg=$msg"
|
||||
else
|
||||
log "ALERT-SEND-FAILED class=$class (cooldown NOT burned)"
|
||||
fi
|
||||
}
|
||||
|
||||
log_rotate() {
|
||||
[ -f "$LOG" ] || return 0
|
||||
local size
|
||||
size=$(stat -c%s "$LOG" 2>/dev/null || echo 0)
|
||||
if [ "$size" -gt "$LOG_MAX" ]; then
|
||||
tail -c $((LOG_MAX / 2)) "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Health probes -----------------------------------------------------------
|
||||
container_running() {
|
||||
docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q "^${CONTAINER}$"
|
||||
}
|
||||
|
||||
container_healthy() {
|
||||
local st started age
|
||||
st=$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null) || return 1
|
||||
if [ "$st" = "healthy" ]; then return 0; fi
|
||||
if [ "$st" = "starting" ]; then
|
||||
# Max-starting-age guard (adversarial review): never park forever on a
|
||||
# container stuck in 'starting'. StartPeriod is 10s; allow 90s slack.
|
||||
started=$(docker inspect -f '{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null)
|
||||
age=$(( $(date +%s) - $(date -d "${started:-1970-01-01}" +%s 2>/dev/null || echo 0) ))
|
||||
[ "$age" -le 90 ]
|
||||
return
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
rogue_on_port() {
|
||||
# any listener on 127.0.0.1:3001 that is NOT docker-proxy
|
||||
ss -H -tlnp 'sport = :3001' 2>/dev/null | grep -v docker-proxy | grep -q .
|
||||
}
|
||||
|
||||
caddy_up() {
|
||||
systemctl is-active --quiet caddy
|
||||
}
|
||||
|
||||
caddy_serving() {
|
||||
# status.sami via loopback; internal CA cert -> -k
|
||||
curl -sk -m 8 -o /dev/null -w '%{http_code}' --resolve status.sami:443:127.0.0.1 https://status.sami/ 2>/dev/null | grep -qE '^[23]'
|
||||
}
|
||||
|
||||
caddyfile_intact() {
|
||||
# Integrity gates (adversarial review hardened): markers + size floor +
|
||||
# service-block floor + caddy syntax validation.
|
||||
[ -f "$CADDYFILE" ] || return 1
|
||||
local refs ca bytes sites
|
||||
refs=$(grep -c 'dashcaddy_auth' "$CADDYFILE" 2>/dev/null || echo 0)
|
||||
ca=$(grep -c 'sami-ca' "$CADDYFILE" 2>/dev/null || echo 0)
|
||||
bytes=$(stat -c%s "$CADDYFILE" 2>/dev/null || echo 0)
|
||||
sites=$(grep -cE '^[a-z0-9.-]+\.(sami|net|com|me|org)[^a-z0-9-]*\{$' "$CADDYFILE" 2>/dev/null || echo 0)
|
||||
[ "$refs" -ge 8 ] && [ "$ca" -ge 1 ] && [ "$bytes" -ge "$CADDYFILE_MIN_BYTES" ] && [ "$sites" -ge "$CADDYFILE_MIN_SITES" ]
|
||||
}
|
||||
|
||||
refresh_known_good() {
|
||||
# Refuse to refresh within 10 minutes of a caddyfile heal (prevents the
|
||||
# partial-write / post-heal window from poisoning the snapshot).
|
||||
local heal_stamp="$STATE_DIR/last-caddyfile-heal"
|
||||
if [ -f "$heal_stamp" ]; then
|
||||
local since=$(( $(date +%s) - $(cat "$heal_stamp" | tr -cd '0-9' || echo 0) ))
|
||||
if [ "$since" -lt 600 ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if caddyfile_intact; then
|
||||
if [ -f "$KNOWN_GOOD" ] && cmp -s "$CADDYFILE" "$KNOWN_GOOD"; then
|
||||
return 0 # unchanged — keep existing snapshot (preserves mtime)
|
||||
fi
|
||||
cp -a "$CADDYFILE" "$KNOWN_GOOD"
|
||||
log "KNOWN-GOOD refreshed ($(stat -c%s "$KNOWN_GOOD" 2>/dev/null || echo '?') bytes)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Remediation --------------------------------------------------------------
|
||||
heal_container_start() {
|
||||
log "HEAL container-start"
|
||||
docker start "$CONTAINER" >/dev/null 2>&1
|
||||
if ! container_running; then
|
||||
log "HEAL container-start failed; falling back to start.sh"
|
||||
bash "$START_SH" >> "$LOG" 2>&1
|
||||
fi
|
||||
notify container "container was down — restarted it"
|
||||
}
|
||||
|
||||
heal_container_recreate() {
|
||||
log "HEAL container-recreate (start.sh)"
|
||||
bash "$START_SH" >> "$LOG" 2>&1
|
||||
notify container "container unhealthy — recreated via start.sh"
|
||||
}
|
||||
|
||||
heal_rogue_port() {
|
||||
local pids
|
||||
pids=$(ss -H -tlnp 'sport = :3001' 2>/dev/null | grep -v docker-proxy | grep -oP 'pid=\K[0-9]+' | sort -u)
|
||||
log "HEAL rogue-port pids=$pids"
|
||||
for pid in $pids; do
|
||||
[ "$pid" = "$$" ] && continue
|
||||
local cmdline
|
||||
cmdline=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ')
|
||||
case "$cmdline" in
|
||||
*docker-proxy*|*dashcaddy-watchdog*) continue ;;
|
||||
esac
|
||||
# PID recycling guard: the process must still be listening on :3001
|
||||
if ! ss -H -tlnp 'sport = :3001' 2>/dev/null | grep -q "pid=$pid"; then
|
||||
continue
|
||||
fi
|
||||
log "KILL pid=$pid cmd=$cmdline"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 3
|
||||
ss -H -tlnp 'sport = :3001' | grep -q docker-proxy || docker restart "$CONTAINER" >/dev/null 2>&1
|
||||
notify rogue-port "rogue host process was holding port 3001 — killed it, container restarted"
|
||||
}
|
||||
|
||||
heal_caddyfile() {
|
||||
log "HEAL caddyfile-restore"
|
||||
if [ -f "$KNOWN_GOOD" ]; then
|
||||
cp -a "$KNOWN_GOOD" "$CADDYFILE"
|
||||
chown caddy:caddy "$CADDYFILE" 2>/dev/null || true
|
||||
systemctl restart caddy
|
||||
notify caddyfile "Caddyfile was wiped/overwritten by a foreign generator — restored known-good and restarted Caddy"
|
||||
else
|
||||
notify caddyfile "Caddyfile damaged and no known-good snapshot exists — MANUAL ACTION NEEDED"
|
||||
fi
|
||||
# Post-heal grace stamp: caddy_serving() skipped for 60s after this point.
|
||||
date +%s > "$STATE_DIR/last-caddyfile-heal"
|
||||
}
|
||||
|
||||
heal_caddy_down() {
|
||||
log "HEAL caddy-restart"
|
||||
systemctl restart caddy
|
||||
notify caddy "caddy was down — restarted it"
|
||||
}
|
||||
|
||||
heal_caddy_5xx() {
|
||||
log "HEAL caddy-5xx"
|
||||
systemctl restart caddy
|
||||
notify caddy "caddy was not serving status.sami (non-2xx/3xx) — restarted it"
|
||||
}
|
||||
|
||||
# --- Main ----------------------------------------------------------------------
|
||||
log_rotate
|
||||
ACTION_TAKEN=0
|
||||
|
||||
# 0. Maintain known-good Caddyfile snapshot whenever current file is intact.
|
||||
refresh_known_good
|
||||
|
||||
# 3. Caddyfile integrity (before caddy health so restore happens first).
|
||||
if ! caddyfile_intact && [ -f "$KNOWN_GOOD" ]; then
|
||||
heal_caddyfile
|
||||
ACTION_TAKEN=1
|
||||
fi
|
||||
|
||||
# 4. Caddy up + serving
|
||||
if ! caddy_up; then
|
||||
heal_caddy_down
|
||||
ACTION_TAKEN=1
|
||||
fi
|
||||
if ! caddy_serving; then
|
||||
# Skip if we JUST restarted caddy this cycle (post-heal grace).
|
||||
if [ -f "$STATE_DIR/last-caddyfile-heal" ]; then
|
||||
local_grace=$(( $(date +%s) - $(cat "$STATE_DIR/last-caddyfile-heal" | tr -cd '0-9' || echo 0) ))
|
||||
if [ "$local_grace" -lt 60 ]; then
|
||||
log "caddy_serving skipped (post-caddyfile-heal grace ${local_grace}s)"
|
||||
else
|
||||
heal_caddy_5xx
|
||||
ACTION_TAKEN=1
|
||||
fi
|
||||
else
|
||||
heal_caddy_5xx
|
||||
ACTION_TAKEN=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Rogue host process on :3001
|
||||
if rogue_on_port; then
|
||||
heal_rogue_port
|
||||
ACTION_TAKEN=1
|
||||
fi
|
||||
|
||||
# 1. Container down/unhealthy (last: start.sh recreates and re-binds 3001)
|
||||
if ! container_running; then
|
||||
heal_container_start
|
||||
ACTION_TAKEN=1
|
||||
elif ! container_healthy; then
|
||||
heal_container_recreate
|
||||
ACTION_TAKEN=1
|
||||
fi
|
||||
|
||||
if [ "$ACTION_TAKEN" = "1" ]; then
|
||||
log "cycle complete: corrective action taken"
|
||||
else
|
||||
log "cycle complete: healthy"
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=DashCaddy self-healing watchdog (container, port 3001, Caddyfile, caddy)
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/dashcaddy-watchdog
|
||||
TimeoutStartSec=90
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run DashCaddy watchdog every 30 seconds
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=30s
|
||||
AccuracySec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Vendored
+96
-95
@@ -1,4 +1,4 @@
|
||||
(function(c){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(h,g,m={}){const b={timestamp:new Date().toISOString(),context:h,message:g instanceof Error?g.message:g,stack:g instanceof Error?g.stack:null,metadata:m};this.errors.push(b),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,g,m)}recoverFromError(h,g){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:g}),{action:"SKIP_STEP",nextStep:g+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:g}),{action:"SKIP_STEP",nextStep:g+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:g}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const g=h.message||h.toString();return g.includes("element")&&g.includes("not found")?"ELEMENT_NOT_FOUND":g.includes("storage")||g.includes("quota")?"STORAGE_UNAVAILABLE":g.includes("driver")||g.includes("undefined")?"DRIVER_NOT_LOADED":g.includes("invalid")||g.includes("validation")?"INVALID_TOOLTIP":g.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(g=>{h.byContext[g.context]=(h.byContext[g.context]||0)+1;const m=this.classifyError({message:g.message});h.byType[m]=(h.byType[m]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
|
||||
(function(c){"use strict";class s{constructor(){this.errors=[],this.maxErrors=50}logError(h,b,y={}){const v={timestamp:new Date().toISOString(),context:h,message:b instanceof Error?b.message:b,stack:b instanceof Error?b.stack:null,metadata:y};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,b,y)}recoverFromError(h,b){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:b}),{action:"SKIP_STEP",nextStep:b+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:b}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const b=h.message||h.toString();return b.includes("element")&&b.includes("not found")?"ELEMENT_NOT_FOUND":b.includes("storage")||b.includes("quota")?"STORAGE_UNAVAILABLE":b.includes("driver")||b.includes("undefined")?"DRIVER_NOT_LOADED":b.includes("invalid")||b.includes("validation")?"INVALID_TOOLTIP":b.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(b=>{h.byContext[b.context]=(h.byContext[b.context]||0)+1;const y=this.classifyError({message:b.message});h.byType[y]=(h.byType[y]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
@@ -16,14 +16,14 @@
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(g){return this.data[g]||null},setItem(g,m){this.data[g]=m},removeItem(g){delete this.data[g]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window);const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const g=await h.json();if(g.tld&&(SITE.tld=g.tld.startsWith(".")?g.tld:"."+g.tld),g.dns&&(SITE.dnsIp=g.dns.ip||"",SITE.dnsPort=g.dns.port||DC.DEFAULTS.DNS_PORT),g.dnsServers&&typeof g.dnsServers=="object")for(const[b,r]of Object.entries(g.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=r);g.configurationType&&(SITE.configurationType=g.configurationType),g.domain&&(SITE.domain=g.domain),g.defaults&&(SITE.defaults=g.defaults),g.routingMode&&(SITE.routingMode=g.routingMode),SITE.onboardingCompleted=g.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const m=document.getElementById("manage-tokens");m&&(m.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const a=document.getElementById("edit-tld-suffix");a&&(a.textContent=SITE.tld);const f=document.getElementById("external-proxy-ip");f&&SITE.dnsIp&&(f.value=SITE.dnsIp,f.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const a=SITE.dnsServers[c];return a?`${a.ip}:${a.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const a=Object.keys(SITE.dnsServers);if(!a.length)return;const f='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;a.forEach(g=>{const m=escapeHtml(g),b=escapeHtml((SITE.dnsServers[g].name||g).toUpperCase()),r=document.createElement("div");r.className="card",r.setAttribute("data-app",g),r.setAttribute("data-status","off"),r.innerHTML=`<span id="${m}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${f}</div><span class="name">${b}</span><span class="spacer"></span><span id="${m}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${m}-time" class="response-time">--</span></div><div class="health-row" id="health-${m}"><span id="uptime-${m}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${m}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${m}-restart" class="restart-btn">Restart</button><button id="${m}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${m}-open">Open</button><button id="${m}-logs" class="logs-btn">Logs</button><button id="${m}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,c.insertBefore(r,h)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const c=await fetch("/api/v1/csrf-token");if(!c.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await c.jsLine truncated
|
||||
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(b){return this.data[b]||null},setItem(b,y){this.data[b]=y},removeItem(b){delete this.data[b]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}c.ErrorHandler=s,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var s=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(s){var m=JSON.parse(s);m.statsPollingInterval&&m.statsPollingInterval>=5&&m.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=m.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const b=await h.json();if(b.tld&&(SITE.tld=b.tld.startsWith(".")?b.tld:"."+b.tld),b.dns&&(SITE.dnsIp=b.dns.ip||"",SITE.dnsPort=b.dns.port||DC.DEFAULTS.DNS_PORT),b.dnsServers&&typeof b.dnsServers=="object")for(const[v,u]of Object.entries(b.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=u);b.configurationType&&(SITE.configurationType=b.configurationType),b.domain&&(SITE.domain=b.domain),b.defaults&&(SITE.defaults=b.defaults),b.routingMode&&(SITE.routingMode=b.routingMode),SITE.onboardingCompleted=b.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const y=document.getElementById("manage-tokens");y&&(y.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const s=document.getElementById("edit-tld-suffix");s&&(s.textContent=SITE.tld);const m=document.getElementById("external-proxy-ip");m&&SITE.dnsIp&&(m.value=SITE.dnsIp,m.placeholder=SITE.dnsIp)})();function buildDomain(c){return c+SITE.tld}function buildServiceUrl(c){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+c:SITE.configurationType==="public"&&SITE.domain?"https://"+c+"."+SITE.domain:"https://"+buildDomain(c)}function getDnsServerAddr(c){const s=SITE.dnsServers[c];return s?`${s.ip}:${s.port}`:buildDomain(c)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[c,s]of Object.entries(SITE.dnsServers))if(s.ip===SITE.dnsIp)return c;return null}function renderDnsCards(){const c=document.querySelector(".top");if(!c)return;const s=Object.keys(SITE.dnsServers);if(!s.length)return;const m='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=c.firstElementChild;s.forEach(b=>{const y=escapeHtml(b),v=escapeHtml((SITE.dnsServers[b].name||b).toUpperCase()),u=document.createElement("div");u.className="card",u.setAttribute("data-app",b),u.setAttribute("data-status","off"),u.innerHTML=`<span id="${y}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${m}</div><span class="name">${v}</span><span class="spacer"></span><span id="${y}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${y}-time" class="response-time">--</span></div><div class="health-row" id="health-${y}"><span id="uptime-${y}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${y}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${y}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${y}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${y}-open" data-i18n="action.open">OpLine truncated
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${m.bg}; color: ${m.fg};
|
||||
background: ${y.bg}; color: ${y.fg};
|
||||
padding: 16px 24px; border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 10000; animation: slideIn 0.3s ease-out;
|
||||
max-width: 400px; white-space: pre-line; font-size: 14px;
|
||||
`,document.body.appendChild(b),f>0&&setTimeout(()=>b.remove(),f)}function timeAgo(c){const a=Date.now()-new Date(c).getTime();return a<6e4?"just now":a<36e5?Math.floor(a/6e4)+"m ago":a<864e5?Math.floor(a/36e5)+"h ago":Math.floor(a/864e5)+"d ago"}function safeGet(c,a=null){try{const f=localStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSet(c,a){try{localStorage.setItem(c,a)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,a=null){try{const f=sessionStorage.getItem(c);return f!==null?f:a}catch{return a}}function safeSessionSet(c,a){try{sessionStorage.setItem(c,a)}catch{}}function safeGetJSON(c,a=null){try{const f=localStorage.getItem(c);return f?JSON.parse(f):a}catch{return a}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,a){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(c,a){var f;((f=this._handlers)[c]||(f[c]=[])).push(a)},off(c,a){this._handlers[c]=this._handlers[c]?.filter(f=>f!==a)},emit(c,a){this._handlers[c]?.forEach(f=>f(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(a=>a.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const a=this._apps.findIndex(f=>f.id===c);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(c,a){const f=this._apps.find(h=>h.id===c);if(f){for(const[h,g]of Object.entries(a))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(f[h]=g);DC_BUS.emit("apps:changed",this._apps)}return f}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function a(h){const g=document.getElementById("cards");if(!(!g||g.querySelector(".card"))){h=h||6;for(let m=0;m<h;m++){const b=c();g.appendChild(b),setTimeout(function(){b.classList.add("loaded")},m*60)}}}function f(){const h=document.getElementById("cards");if(h){var g=h.querySelectorAll(".skeleton-card");g.length&&(g.forEach(function(m,b){setTimeout(function(){m.style.opacity="0",m.style.transform="translateY(-10px)"},b*25)}),setTimeout(function(){g.forEach(function(m){m.parentNode&&m.remove()})},g.length*25+300))}}window.SkeletonLoader={show:a,hide:f}})(),(function(){var c="theme",a="user-themes",f="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],g=h.slice(),m=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],b=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],r=["fg-muted","hover","card-hover","base","success","error","warning"],v={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0",
Line truncated
|
||||
`,document.body.appendChild(v),m>0&&setTimeout(()=>v.remove(),m)}function timeAgo(c){const s=Date.now()-new Date(c).getTime();return s<6e4?"just now":s<36e5?Math.floor(s/6e4)+"m ago":s<864e5?Math.floor(s/36e5)+"h ago":Math.floor(s/864e5)+"d ago"}function safeGet(c,s=null){try{const m=localStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSet(c,s){try{localStorage.setItem(c,s)}catch{}}function safeRemove(c){try{localStorage.removeItem(c)}catch{}}function safeSessionGet(c,s=null){try{const m=sessionStorage.getItem(c);return m!==null?m:s}catch{return s}}function safeSessionSet(c,s){try{sessionStorage.setItem(c,s)}catch{}}function safeGetJSON(c,s=null){try{const m=localStorage.getItem(c);return m?JSON.parse(m):s}catch{return s}}function escapeHtml(c){return String(c??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(c,s){document.getElementById(c)||document.body.insertAdjacentHTML("beforeend",s)}const DC_BUS={_handlers:{},on(c,s){var m;((m=this._handlers)[c]||(m[c]=[])).push(s)},off(c,s){this._handlers[c]=this._handlers[c]?.filter(m=>m!==s)},emit(c,s){this._handlers[c]?.forEach(m=>m(s))}},AppState={_apps:[],getApps(){return this._apps},setApps(c){this._apps=c,window.APPS=c,DC_BUS.emit("apps:changed",c)},findApp(c){return this._apps.find(s=>s.id===c)},addApp(c){this._apps.push(c),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(c){const s=this._apps.findIndex(m=>m.id===c);return s>-1&&(this._apps.splice(s,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),s>-1},updateApp(c,s){const m=this._apps.find(h=>h.id===c);if(m){for(const[h,b]of Object.entries(s))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(m[h]=b);DC_BUS.emit("apps:changed",this._apps)}return m}};(function(){function c(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function s(h){const b=document.getElementById("cards");if(!(!b||b.querySelector(".card"))){h=h||6;for(let y=0;y<h;y++){const v=c();b.appendChild(v),setTimeout(function(){v.classList.add("loaded")},y*60)}}}function m(){const h=document.getElementById("cards");if(h){var b=h.querySelectorAll(".skeleton-card");b.length&&(b.forEach(function(y,v){setTimeout(function(){y.style.opacity="0",y.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){b.forEach(function(y){y.parentNode&&y.remove()})},b.length*25+300))}}window.SkeletonLoader={show:s,hide:m}})(),(function(){var c="theme",s="user-themes",m="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],b=h.slice(),y=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],v=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],u=["fg-muted","hover","card-hover","base","success","error","warning"],l={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0",
Line truncated
|
||||
background:
|
||||
radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%),
|
||||
@@ -35,7 +35,7 @@
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`}function l(C,B){var $=B.lightBg||B.bg&&s(B.bg)>.4;return $?":root."+C+` button:hover {
|
||||
`}function i(C,B){var $=B.lightBg||B.bg&&d(B.bg)>.4;return $?":root."+C+` button:hover {
|
||||
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
|
||||
border-color: rgba(0, 0, 0, .15);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
|
||||
@@ -44,20 +44,20 @@
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function i(){m.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function y(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var k=safeGetJSON(a,{}),x=$,I=2;k[$]&&$!==B;)$=x+"-"+I++;return $}function u(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),g.length=h.length,Object.keys(v).forEach(function(E){h.indexOf(E)===-1&&delete v[E]});var $=C||safeGetJSON(a,{}),k=Object.keys($);if(k=k.filter(function(E){return h.indexOf(E)===-1}),!!k.length){var x="";k.forEach(function(E){var S=$[E];g.indexOf(E)===-1&&g.push(E);var A={};m.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);r.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),v[E]=A,x+=":root."+E+` {
|
||||
`,m.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
|
||||
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){y.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function f(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),h.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(s,{}),x=$,S=2;E[$]&&$!==B;)$=x+"-"+S++;return $}function p(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),b.length=h.length,Object.keys(l).forEach(function(T){h.indexOf(T)===-1&&delete l[T]});var $=C||safeGetJSON(s,{}),E=Object.keys($);if(E=E.filter(function(T){return h.indexOf(T)===-1}),!!E.length){var x="";E.forEach(function(T){var k=$[T];b.indexOf(T)===-1&&b.push(T);var A={};y.forEach(function(O){k[O]&&(A[O]=k[O])}),A["card-bg"]=k["card-base"]||k.bg,k.lightBg&&(A.lightBg=!0);var D=t(A);u.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),l[T]=A,x+=":root."+T+` {
|
||||
`,y.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
|
||||
`)}),x+=`}
|
||||
`,x+=n(E,A),x+=l(E,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(a,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(a,JSON.stringify(B)),u(B);var k=safeGet(c);k&&g.indexOf(k)!==-1&&L(k)}}}).catch(function(){})}function T(){var C=safeGetJSON(f);if(C){var B=C.name||"Custom",$=y(B),k={name:B};m.forEach(function(E){C[E]&&(k[E]=C[E])});var x=safeGetJSON(a,{});x[$]=k,safeSet(a,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(f);var I={};m.forEach(function(E){k[E]&&(I[E]=k[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),g.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),i(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=v[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var k=B&&B.lightBg;!k&&B&&B.bg&&(k=s(B.bg)>.4),k?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),u();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&g.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||L(C.matches?"dark":"light")}),window.THEMES=g,window.BUILTIN_THEMES=h,window.THEME_COLORS=v,window.THEME_PROPS=m,window.BASE_PROPS=b,window.DERIVED_PROPS=r,window.USER_THEMES_KEY=a,window.applyTheme=L,window.clearCustomProperties=i,window.injectUserThemeStyles=u,window.syncThemesFromServer=w,window.slugifyThemeName=y,window.getActiveTheme=function(){return safeGet(c)||t()},window.deriveExtendedColors=e,window.hexToRgb=d,window.rgbToHex=o,window.blendColors=p})(),(function(){let c=null;async function a(){if(c)return c;try{const d=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!d.ok)throw new Error(`methods HTTP ${d.status}`);const o=await d.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(d){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",d),[]}}function f(d){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(!p)return;const s=p.innerHTML;p.dataset.originalBody||(p.dataset.originalBody=s);const e=d.map(n=>{const l=n.config&&(n.config.label||n.name)||n.name;return`<button class="provider-btn" data-provider="${n.name}"
|
||||
`,x+=e(T,A),x+=i(T,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=x,document.head.appendChild(S)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(s,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(s,JSON.stringify(B)),p(B);var E=safeGet(c);E&&b.indexOf(E)!==-1&&L(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(m);if(C){var B=C.name||"Custom",$=f(B),E={name:B};y.forEach(function(T){C[T]&&(E[T]=C[T])});var x=safeGetJSON(s,{});x[$]=E,safeSet(s,JSON.stringify(x)),safeGet(c)==="custom"&&safeSet(c,$),safeRemove(m);var S={};y.forEach(function(T){E[T]&&(S[T]=E[T])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:S})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),b.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),r(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(c,C);var B=l[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=d(B.bg)>.4),E?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}I(),p();var P=safeGet(c);P==="red"&&(P="black",safeSet(c,"black")),P&&P!=="dark"&&b.indexOf(P)===-1&&(P=null),L(P||n()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(c)||L(C.matches?"dark":"light")}),window.THEMES=b,window.BUILTIN_THEMES=h,window.THEME_COLORS=l,window.THEME_PROPS=y,window.BASE_PROPS=v,window.DERIVED_PROPS=u,window.USER_THEMES_KEY=s,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=f,window.getActiveTheme=function(){return safeGet(c)||n()},window.deriveExtendedColors=t,window.hexToRgb=a,window.rgbToHex=o,window.blendColors=g})(),(function(){let c=null;async function s(){if(c)return c;try{const a=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!a.ok)throw new Error(`methods HTTP ${a.status}`);const o=await a.json();return c=Array.isArray(o.providers)?o.providers:[],c}catch(a){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",a),[]}}function m(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(!g)return;const d=g.innerHTML;g.dataset.originalBody||(g.dataset.originalBody=d);const t=a.map(e=>{const i=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
|
||||
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
|
||||
Sign in with ${l}
|
||||
Sign in with ${i}
|
||||
</button>`}).join(`
|
||||
`);p.innerHTML=`
|
||||
`);g.innerHTML=`
|
||||
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<p class="subtitle">Choose how to sign in</p>
|
||||
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${e}</div>
|
||||
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${t}</div>
|
||||
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
|
||||
`,o.classList.add("show"),p.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const l=n.dataset.provider,t=d.find(i=>i.name===l);g(t)})})}function h(){const d=document.getElementById("totp-overlay");if(!d)return;const o=d.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function g(d){const o=document.getElementById("totp-overlay");if(!o)return;const p=o.querySelector(".totp-card");if(p){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){p.innerHTML=`
|
||||
`,o.classList.add("show"),g.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const i=e.dataset.provider,n=a.find(r=>r.name===i);b(n)})})}function h(){const a=document.getElementById("totp-overlay");if(!a)return;const o=a.querySelector(".totp-card");!o||!o.dataset.originalBody||(o.innerHTML=o.dataset.originalBody,window.location.reload())}function b(a){const o=document.getElementById("totp-overlay");if(!o)return;const g=o.querySelector(".totp-card");if(g){if(a.name==="totp"){window.location.reload();return}if(a.name==="email"){g.innerHTML=`
|
||||
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<p class="subtitle">Sign in with email</p>
|
||||
@@ -75,13 +75,13 @@
|
||||
<div style="margin-top: 16px;">
|
||||
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
|
||||
</div>
|
||||
`,o.classList.add("show");const s=p.querySelector("#auth-gate-email-input"),e=p.querySelector("#auth-gate-email-submit"),n=p.querySelector("#auth-gate-email-status"),l=p.querySelector("#auth-gate-back");e.addEventListener("click",async()=>{const t=(s.value||"").trim();if(!t||!t.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}e.disabled=!0,n.textContent="Sending\u2026",n.style.color="";try{const y=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:t})})).json();if(y.success){const u=y.deliveredVia||"email",w=y.maskedEmail||t;u==="dev-console"?n.innerHTML=`
|
||||
`,o.classList.add("show");const d=g.querySelector("#auth-gate-email-input"),t=g.querySelector("#auth-gate-email-submit"),e=g.querySelector("#auth-gate-email-status"),i=g.querySelector("#auth-gate-back");t.addEventListener("click",async()=>{const n=(d.value||"").trim();if(!n||!n.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}t.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const f=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})})).json();if(f.success){const p=f.deliveredVia||"email",w=f.maskedEmail||n;p==="dev-console"?e.innerHTML=`
|
||||
Check the server logs for your one-time link.
|
||||
(Dev mode: no SMTP configured. In production this would email <strong>${w}</strong>.)`:n.innerHTML=`Sign-in link sent to <strong>${w}</strong>.
|
||||
Check your inbox (and spam folder).`,n.style.color="var(--success, #2a7)"}else n.textContent=y.error||"Could not send link.",n.style.color="var(--error, #d33)",e.disabled=!1}catch{n.textContent="Connection error. Try again.",n.style.color="var(--error, #d33)",e.disabled=!1}}),s.addEventListener("keydown",t=>{t.key==="Enter"&&e.click()}),l.addEventListener("click",t=>{t.preventDefault(),f(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function m(){if(!document.getElementById("totp-overlay"))return;const o=await a();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){b(o[0]);return}f(o)}function b(d){const o=c&&c.find(n=>n.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const p=document.getElementById("totp-overlay"),s=p.querySelector(".totp-card");if(!s)return;if(s.dataset.originalBody||(s.dataset.originalBody=s.innerHTML),!s.querySelector("#auth-gate-email-alt")){const n=document.createElement("div");n.id="auth-gate-email-alt",n.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",n.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
|
||||
(Dev mode: no SMTP configured. In production this would email <strong>${w}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${w}</strong>.
|
||||
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=f.error||"Could not send link.",e.style.color="var(--error, #d33)",t.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",t.disabled=!1}}),d.addEventListener("keydown",n=>{n.key==="Enter"&&t.click()}),i.addEventListener("click",n=>{n.preventDefault(),m(providers)});return}console.warn("[auth-gate] unknown provider",a.name),window.location.reload()}}async function y(){if(!document.getElementById("totp-overlay"))return;const o=await s();if(o.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(o.length===1&&o[0].name==="totp"){v(o[0]);return}m(o)}function v(a){const o=c&&c.find(e=>e.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const g=document.getElementById("totp-overlay"),d=g.querySelector(".totp-card");if(!d)return;if(d.dataset.originalBody||(d.dataset.originalBody=d.innerHTML),!d.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
|
||||
style="color: var(--accent); text-decoration: none;">
|
||||
Or sign in with email instead \u2192
|
||||
</a>`,s.appendChild(n),n.querySelector("#auth-gate-email-alt-link").addEventListener("click",l=>{l.preventDefault(),g(o)})}p.classList.add("show")}window.__dc_049_handled=!0;function r(d){try{const o=new URL(d,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const p=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===p.slice(1)||o.hostname.endsWith(p)}catch{return!1}}const v=new URLSearchParams(window.location.search);if(v.get("auth")==="required"){const d=v.get("return");if(d&&r(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(m,0)}window._showAuthGate=m,window._authGateMethods=a})(),(function(){function c(){const r=document.querySelector(".totp-card");if(!r)return;const d=getComputedStyle(r).backgroundColor.match(/\d+/g);if(!d)return;const o=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,p=r.querySelector(".totp-logo-dark"),s=r.querySelector(".totp-logo-light");p&&(p.style.display=o>.5?"none":""),s&&(s.style.display=o>.5?"":"none")}function a(){const r=document.getElementById("totp-overlay");if(r){r.classList.add("show"),setTimeout(c,50);const v=r.querySelector(".totp-digits input");v&&setTimeout(()=>v.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function f(){const r=document.getElementById("totp-overlay");r&&r.classList.remove("show")}function h(r,v){const d=new URL(r,window.location.origin);if(d.origin===window.location.origin)return d.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,p=d.hostname===o.slice(1)||d.hostname.endsWith(o);if(d.protocol!=="https:"||!p)return null;if(!v)return d.toString();const s=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",v),d.searchParams.set("return",s),d.toString()}const g=document.getElementById("totp-digits");if(g){const r=g.querySelectorAll("input");r.forEach((v,d)=>{v.addEventListener("input",o=>{const p=o.target.value.replace(/\D/g,"");o.target.value=p.slice(0,1),p&&d<r.length-1&&r[d+1].focus();const s=Array.from(r).map(e=>e.value).join("");s.length===6&&m(s)}),v.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&d>0&&(r[d-1].focus(),r[d-1].value="")}),v.addEventListener("paste",o=>{o.preventDefault();const p=(o.clipboardData.getData("text")||"").replace(/\D/g,"");p.length>=6&&(r.forEach((s,e)=>{s.value=p[e]||""}),r[5].focus(),m(p.slice(0,6)))})})}async function m(r){const v=document.getElementById("totp-error");v.textContent="Verifying...",v.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:r})})).json();if(o.success){v.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),f();const p=safeSessionGet("totp_redirect");if(p){try{sessionStorage.removeItem("totp_redirect")}catch{}const s=h(p,o.ssoToken);if(!s)return;window.location.href=s;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{v.textContent=o.error||"Invalid code",v.className="totp-error";const p=document.querySelectorAll("#totp-digits input");p.forEach(s=>{s.value=""}),p[0]?.focus()}}catch{v.textContent="Connection error",v.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const r=urlParams.get("return");if(r)try{const v=new URL(r,window.location.origin),d=v.hostname,o=v.origin===window.location.origin,p=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,s=d.endsWith(p)||d===p.substring(1);(o||s)&&safeSessionSet("totp_redirect",r)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=a})(),(function(){"use strict";async function c(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function a(b){const r=document.getElementById("totp-recovery-link");r&&(r.style.display=b?"":"none")}function f(){const b=document.getElementById("totp-recovery-panel");b&&(b.style.display="");const r=document.getElementById("totp-recovery-status"),v=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");v&&(v.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",c().then(o=>{r.textContent=o.hint||"",o.status==="healthy"?r.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(r.style.borderColor="var(--bad-fg, #ff9aa3)",r.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?r.style.borderColor="var(--muted)"Line truncated
|
||||
</a>`,d.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",i=>{i.preventDefault(),b(o)})}g.classList.add("show")}window.__dc_049_handled=!0;function u(a){try{const o=new URL(a,window.location.origin);if(!["http:","https:"].includes(o.protocol))return!1;if(o.origin===window.location.origin)return!0;if(o.protocol!=="https:")return!1;const g=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===g.slice(1)||o.hostname.endsWith(g)}catch{return!1}}const l=new URLSearchParams(window.location.search);if(l.get("auth")==="required"){const a=l.get("return");if(a&&u(a))try{sessionStorage.setItem("totp_redirect",a)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(y,0)}window._showAuthGate=y,window._authGateMethods=s})(),(function(){function c(){const u=document.querySelector(".totp-card");if(!u)return;const a=getComputedStyle(u).backgroundColor.match(/\d+/g);if(!a)return;const o=(.299*+a[0]+.587*+a[1]+.114*+a[2])/255,g=u.querySelector(".totp-logo-dark"),d=u.querySelector(".totp-logo-light");g&&(g.style.display=o>.5?"none":""),d&&(d.style.display=o>.5?"":"none")}function s(){const u=document.getElementById("totp-overlay");if(u){u.classList.add("show"),setTimeout(c,50);const l=u.querySelector(".totp-digits input");l&&setTimeout(()=>l.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function m(){const u=document.getElementById("totp-overlay");u&&u.classList.remove("show")}function h(u,l){const a=new URL(u,window.location.origin);if(a.origin===window.location.origin)return a.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,g=a.hostname===o.slice(1)||a.hostname.endsWith(o);if(a.protocol!=="https:"||!g)return null;if(!l)return a.toString();const d=`${a.pathname}${a.search}${a.hash}`;return a.pathname="/dashcaddy-sso",a.search="",a.hash="",a.searchParams.set("token",l),a.searchParams.set("return",d),a.toString()}const b=document.getElementById("totp-digits");if(b){const u=b.querySelectorAll("input");u.forEach((l,a)=>{l.addEventListener("input",o=>{const g=o.target.value.replace(/\D/g,"");o.target.value=g.slice(0,1),g&&a<u.length-1&&u[a+1].focus();const d=Array.from(u).map(t=>t.value).join("");d.length===6&&y(d)}),l.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&a>0&&(u[a-1].focus(),u[a-1].value="")}),l.addEventListener("paste",o=>{o.preventDefault();const g=(o.clipboardData.getData("text")||"").replace(/\D/g,"");g.length>=6&&(u.forEach((d,t)=>{d.value=g[t]||""}),u[5].focus(),y(g.slice(0,6)))})})}async function y(u){const l=document.getElementById("totp-error");l.textContent="Verifying...",l.className="totp-error verifying";try{const o=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:u})})).json();if(o.success){l.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),m();const g=safeSessionGet("totp_redirect");if(g){try{sessionStorage.removeItem("totp_redirect")}catch{}const d=h(g,o.ssoToken);if(!d)return;window.location.href=d;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard(),window._dcAuthLost=!1,typeof window._sseResume=="function"?window._sseResume():typeof window._sseReconnect=="function"&&window._sseReconnect(),typeof window.refreshAll=="function"&&window.refreshAll()}else{l.textContent=o.error||"Invalid code",l.className="totp-error";const g=document.querySelectorAll("#totp-digits input");g.forEach(d=>{d.value=""}),g[0]?.focus()}}catch{l.textContent="Connection error",l.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const u=urlParams.get("return");if(u)try{const l=new URL(u,window.location.origin),a=l.hostname,o=l.origin===window.location.origin,g=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,d=a.endsWith(g)||a===g.substring(1);(o||d)&&safeSessionSet("totp_redirect",u)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=s})(),(function(){"use strict";async function c(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function s(v){const u=document.getElementById("totp-recovery-link");u&&(u.style.display=v?"":"none")}function m(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const u=document.getElementById("totp-recovery-status"),l=document.getElementById("totp-recovery-import"),a=document.getElementById("totp-recovery-verify");l&&(l.style.display=""),a&&(a.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",c().then(o=>{u.textContent=o.hint||"",o.status==="healthy"?u.style.borderColor="var(--ok-fg, #7ef2ff)":o.sLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
||||
<h3>\u{1F4C2} Browse for Media Folders</h3>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const a=document.getElementById("service-creds-modal");let f=null;const h=["sonarr","radarr","prowlarr","overseerr"],g=["sonarr","radarr"];function m(o){return o.externalUrl||o.url||""}function b(o){const p=document.getElementById("svc-creds-error");p.textContent=o,p.style.display=""}function r(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){f=o,r();const p=document.getElementById("svc-creds-title"),s=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),n=document.getElementById("svc-creds-apikey"),l=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");p.textContent=o.name+" Credentials";const i=!!o.isExternal,y=h.includes(o.id)||h.includes(o.appTemplate),u=g.includes(o.id)||g.includes(o.appTemplate);e.style.display=i?"":"none",n.style.display=y?"":"none",t.style.display=u?"":"none",l.style.display=i?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",i?(s.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):y?s.textContent="API key bypasses the app login screen automatically.":s.textContent="Credentials are injected automatically when accessing this service.",await v(o),a.classList.add("show")};async function v(o){const p=document.getElementById("svc-creds-dot"),s=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let n=!1;if(o.isExternal){try{const i=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();i.success?(document.getElementById("svc-seedhost-user").value=i.username||"",i.hasCredentials&&(n=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const i=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();i.success&&(i.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",n=!0):document.getElementById("svc-apikey-input").value="",i.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=i.username||"",n=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const l=o.id||o.appTemplate;if(g.includes(l)&&await d(o),n){p.style.background="var(--ok-fg, #74dfc4)",s.style.color="var(--ok-fg, #74dfc4)",s.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${o.id}`);t&&t.classList.add("has-creds")}else p.style.background="var(--muted)",s.style.color="var(--muted)",s.textContent="No credentials stored",e.style.display="none"}async function d(o){const p=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status"),e=o.id||o.appTemplate,n=m(o);if(!n){p.innerHTML='<option value="">-- No service URL --</option>';return}p.innerHTML='<option value="">Loading...</option>',s.textContent="";try{const l=new URLSearchParams({service:e,url:n}),i=await(await fetch(`/api/v1/arr/quality-profiles?${l}`)).json();if(!i.success||!i.profiles?.length){p.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}p.innerHTML="";for(const y of i.profiles){const u=document.createElement("option");u.value=y.id,u.textContent=y.name,p.appendChild(u)}if(i.storedProfileId&&(p.value=String(i.storedProfileId)),!p.value){const y=i.profiles.find(u=>/720/i.test(u.name));y&&(p.value=String(y.id))}!p.value&&i.profiles.length&&(p.value=String(i.profiles[0].id)),s.innerHTML=`<span style="color: var(--ok-fg);">${i.profiles.length} profiles loaded</span>`}catch(l){p.innerHTML='<option value="">-- Failed to load --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${l.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!f)return;const o=f.id||f.appTemplate,p=m(f),e=document.getElementById("svc-apikey-input")?.value.trim(),n=document.getElementById("svc-quality-select"),l=document.getElementById("svc-quality-status");if(!p){l.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){l.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}n.innerHTML='<option value="">Fetching...</option>',l.textContent="";try{const t=new URLSearchParams({service:o,url:p,apiKey:e}),y=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!y.success){n.innerHTML='<option value="">-- Error --</option>',l.innerHTML=`<span style="color: var(--error, #c62828);">${y.error||"Failed to feLine truncated
|
||||
</div>`);const s=document.getElementById("service-creds-modal");let m=null;const h=["sonarr","radarr","prowlarr","overseerr"],b=["sonarr","radarr"];function y(o){return o.externalUrl||o.url||""}function v(o){const g=document.getElementById("svc-creds-error");g.textContent=o,g.style.display=""}function u(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){m=o,u();const g=document.getElementById("svc-creds-title"),d=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),i=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");g.textContent=o.name+" Credentials";const r=!!o.isExternal,f=h.includes(o.id)||h.includes(o.appTemplate),p=b.includes(o.id)||b.includes(o.appTemplate);t.style.display=r?"":"none",e.style.display=f?"":"none",n.style.display=p?"":"none",i.style.display=r?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",r?(d.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):f?d.textContent="API key bypasses the app login screen automatically.":d.textContent="Credentials are injected automatically when accessing this service.",await l(o),s.classList.add("show")};async function l(o){const g=document.getElementById("svc-creds-dot"),d=document.getElementById("svc-creds-status"),t=document.getElementById("svc-creds-clear");let e=!1;if(o.isExternal){try{const r=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();r.success?(document.getElementById("svc-seedhost-user").value=r.username||"",r.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const r=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();r.success&&(r.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",r.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=r.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const i=o.id||o.appTemplate;if(b.includes(i)&&await a(o),e){g.style.background="var(--ok-fg, #74dfc4)",d.style.color="var(--ok-fg, #74dfc4)",d.textContent="Credentials stored",t.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else g.style.background="var(--muted)",d.style.color="var(--muted)",d.textContent="No credentials stored",t.style.display="none"}async function a(o){const g=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status"),t=o.id||o.appTemplate,e=y(o);if(!e){g.innerHTML='<option value="">-- No service URL --</option>';return}g.innerHTML='<option value="">Loading...</option>',d.textContent="";try{const i=new URLSearchParams({service:t,url:e}),r=await(await fetch(`/api/v1/arr/quality-profiles?${i}`)).json();if(!r.success||!r.profiles?.length){g.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}g.innerHTML="";for(const f of r.profiles){const p=document.createElement("option");p.value=f.id,p.textContent=f.name,g.appendChild(p)}if(r.storedProfileId&&(g.value=String(r.storedProfileId)),!g.value){const f=r.profiles.find(p=>/720/i.test(p.name));f&&(g.value=String(f.id))}!g.value&&r.profiles.length&&(g.value=String(r.profiles[0].id)),d.innerHTML=`<span style="color: var(--ok-fg);">${r.profiles.length} profiles loaded</span>`}catch(i){g.innerHTML='<option value="">-- Failed to load --</option>',d.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${i.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!m)return;const o=m.id||m.appTemplate,g=y(m),t=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status");if(!g){i.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!t||t==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){i.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',i.textContent="";try{const n=new URLSearchParams({service:o,url:g,apiKey:t}),f=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!f.success){e.innerHTML='<option value="">-- Error --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">${f.error||"Failed to feLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);async function a(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:b,sessionDuration:r,isSetUp:v}=m.config,d=document.getElementById("totp-status-dot"),o=document.getElementById("totp-status-text"),p=document.getElementById("totp-status-banner"),s=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),l=document.getElementById("totp-disable-section");if(b&&v){d.style.background="var(--ok-fg, #7ef2ff)",p.style.borderColor="var(--ok-fg, #7ef2ff)",p.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",o.textContent="TOTP is active",o.style.color="var(--ok-fg, #7ef2ff)",s.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",l.style.display="block",document.getElementById("totp-duration-select").value=r}else d.style.background="var(--muted)",p.style.borderColor="var(--border)",p.style.background="transparent",o.textContent="TOTP is not configured",o.style.color="var(--muted)",s.style.display="block",e.style.display="none",n.style.display="none",l.style.display="none";h(b&&v,r)}catch(g){console.warn("Failed to load TOTP settings:",g)}}const f={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(g,m){const b=document.getElementById("auth-card"),r=document.getElementById("auth-pill"),v=document.getElementById("auth-dot"),d=document.getElementById("auth-status-text");b&&(g?(b.setAttribute("data-status","on"),r.className="badge on",r.textContent="YES",v.className="dot ok at-bl",d.textContent="Session: "+(f[m]||m)):(b.setAttribute("data-status","off"),r.className="badge off",r.textContent="NO",v.className="dot bad at-bl",d.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(g){c.logError("[TOTP] Setup Failed",g,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const g=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!g){m.textContent="Paste a Base32 secret key first";return}try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:g})})).json();r.success?(m.textContent="",document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):m.textContent=r.error||r.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(g).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const g=document.getElementById("totp-manual-key").textContent.trim();if(!g)return;const m={service:"DashCaddy",type:"totp-secret",secret:g,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},b=new Blob([JSON.stringify(m,null,2)],{type:"application/json"}),r=URL.createObjectURL(b),v=document.createElement("a");v.href=r,v.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(v),v.click(),document.body.removeChild(v),URL.revokeObjectURL(r);const d=document.getElementById("totp-download-backup");d.textContent="\u2705 Saved",setTimeout(()=>{d.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const g=doLine truncated
|
||||
</div>`);async function s(){try{const v=await(await fetch("/api/v1/totp/config")).json();if(!v.success)return;const{enabled:u,sessionDuration:l,isSetUp:a}=v.config,o=document.getElementById("totp-status-dot"),g=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),i=document.getElementById("totp-duration-section"),n=document.getElementById("totp-disable-section");if(u&&a){o.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",g.textContent="TOTP is active",g.style.color="var(--ok-fg, #7ef2ff)",t.style.display="block";const r=document.getElementById("totp-setup-btn");r&&(r.textContent="Generate New Secret"),e.style.display="none",i.style.display="block",n.style.display="block",document.getElementById("totp-duration-select").value=l}else o.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",g.textContent="TOTP is not configured",g.style.color="var(--muted)",t.style.display="block",e.style.display="none",i.style.display="none",n.style.display="none";b(u&&a,l)}catch(y){console.warn("Failed to load TOTP settings:",y)}}const m={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(y,v){const u=window.DCI18n;return u&&u.isLoaded()?u.t(y):v}function b(y,v){const u=document.getElementById("auth-card"),l=document.getElementById("auth-pill"),a=document.getElementById("auth-dot"),o=document.getElementById("auth-status-text");u&&(y?(u.setAttribute("data-status","on"),l.className="badge on",l.textContent=h("card.status.yes","YES"),a.className="dot ok at-bl",o.textContent="Session: "+(m[v]||v)):(u.setAttribute("data-status","off"),l.className="badge off",l.textContent=h("card.status.no","NO"),a.className="dot bad at-bl",o.textContent=h("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const v=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();v.success&&(document.getElementById("totp-qr-image").src=v.qrCode,document.getElementById("totp-manual-key").textContent=v.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(y){c.logError("[TOTP] Setup Failed",y,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const y=document.getElementById("totp-import-key").value.trim(),v=document.getElementById("totp-import-error");if(v.textContent="",!y){v.textContent="Paste a Base32 secret key first";return}try{const l=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:y})})).json();l.success?(v.textContent="",document.getElementById("totp-qr-image").src=l.qrCode,document.getElementById("totp-manual-key").textContent=l.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):v.textContent=l.error||l.message||"Import failed"}catch{v.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(y).then(()=>{const v=document.getElementById("totp-copy-key");v.textContent="\u2705",setTimeout(()=>{v.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const y=document.getElementById("totp-manual-key").textContent.trim();if(!y)return;const v={service:"DashCaddy",type:"totp-secret",secret:y,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},u=new Blob([JSON.stringify(v,null,2)],{type:"application/json"}),l=URL.createObjectURL(u),a=document.createElement("a");a.href=l,a.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(l);const o=document.getElementById("totp-download-backup");o.textContent="\u2705 Saved",setTimeLine truncated
|
||||
<div id="token-management-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3>\u{1F511} DNS Credentials</h3>
|
||||
@@ -306,40 +306,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);function c(){return Object.keys(SITE.dnsServers||{})}function a(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function f(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const i=c();if(i.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const y of i)t.insertAdjacentHTML("beforeend",`
|
||||
`);function c(){return Object.keys(SITE.dnsServers||{})}function s(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function m(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const r=c();if(r.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const f of r)n.insertAdjacentHTML("beforeend",`
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">${a(y)}</h4>
|
||||
<h4 class="token-section-title">${s(f)}</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="${y}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${y}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<label for="${f}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${f}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${y}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${y}-readonly-token">\u{1F441}</button>
|
||||
<input type="password" id="${f}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${f}-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="${y}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${y}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<label for="${f}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${f}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${y}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${y}-admin-token">\u{1F441}</button>
|
||||
<input type="password" id="${f}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${f}-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="${y}-token-status"></div>
|
||||
<div class="token-status" id="${f}-token-status"></div>
|
||||
</div>
|
||||
`)}function h(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const y=new Uint8Array(32);return crypto.getRandomValues(y),t=Array.from(y,u=>u.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const g=h();function m(t,i){if(!t)return"";const y=crypto.getRandomValues(new Uint8Array(8)),u=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(i+u);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^w[L%w.length]^y[L%y.length]^L*31+17&255;T+=String.fromCharCode(P)}return u+":"+btoa(T)}function b(t,i){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),C=new TextEncoder().encode(i+T);let B="";for(let $=0;$<P.length;$++){const k=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(k)}return B}const u=atob(t);let w="";for(let T=0;T<u.length;T++){const L=u.charCodeAt(T)^i.charCodeAt(T%i.length);w+=String.fromCharCode(L)}return w}catch{return""}}function r(t,i,y){const u=safeGet(`${t}-${i}-${y}-enc`);return b(u,g)}function v(t,i,y,u){const w=`${t}-${i}-${y}-enc`;u?safeSet(w,m(u,g)):safeRemove(w)}function d(t,i){return r(t,i,"token")}function o(t,i){return r(t,i,"username")}function p(t,i,y){v(t,i,"token",y)}function s(t,i,y){v(t,i,"username",y)}function e(){const t={};for(const i of c())t[i]={readonly:{username:o(i,"readonly"),token:d(i,"readonly")},admin:{username:o(i,"admin"),token:d(i,"admin")}};return t}function n(){c().forEach(t=>{["readonly","admin"].forEach(i=>{["token","username"].forEach(y=>{safeRemove(`${t}-${i}-${y}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function l(t){const i=d(t,"readonly"),y=o(t,"readonly"),u=d(t,"admin"),w=o(t,"admin"),T=b(safeGet(`${t}-token-enc`),g),L=b(safeGet(`${t}-username-enc`),g);return{username:w||y||L,token:u||i||T,readonlyToken:i||T,readonlyUsername:y||L,adminToken:u||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{f();const t=document.getElementById("token-management-modal"),i=e();c().forEach(y=>{const u=i[y];document.getElementById(`${y}-readonly-username`).value=u.readonly.username,document.getElementById(`${y}-readonly-token`).value=u.readonly.token,document.getElementById(`${y}-admin-username`).value=u.admin.username,document.getElementById(`${y}-admin-token`).value=u.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const i=t.target.closest(".token-toggle");if(i){const y=i.dataset.target,u=document.getElementById(y);u.type==="password"?(u.type="text",i.textContent="\u{1F648}"):(u.type="password",i.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=c();t.forEach(u=>{s(u,"readonly",document.getElementById(`${u}-readonly-username`).value.trim()),p(u,"readonly",document.getElementById(`${u}-readonly-token`).value.trim()),s(u,"admin",document.getElementById(`${u}-admin-username`).value.trim()),p(u,"admin",document.getElementById(`${u}-admin-token`).value.trim())});const i={};let y=!1;if(t.forEach(u=>{const w={},T=document.getElementById(`${u}-readonly-username`).value.trim(),L=document.getElementById(`${u}-readonly-token`).value.trim(),P=document.getElementById(`${u}-admin-username`).value.trim(),C=document.getElementById(`${u}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},y=!0),P&&C&&(w.admin={username:P,password:C},y=!0),Object.keys(w).length>0&&(i[u]=w)}),y){t.forEach(u=>{i[u]&&(document.getElementById(`${u}-token-status`).textContent="Verifying...",document.getElementById(`${u}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:i})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!i[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(u){console.erro
Line truncated
|
||||
`)}function h(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const r=safeGet("dashcaddy-encryption-key");if(r)return safeSessionSet("dashcaddy-encryption-key",r),safeRemove("dashcaddy-encryption-key"),r;const f=new Uint8Array(32);return crypto.getRandomValues(f),n=Array.from(f,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const b=h();function y(n,r){if(!n)return"";const f=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(f,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(r+p);let I="";for(let L=0;L<n.length;L++){const P=n.charCodeAt(L)^w[L%w.length]^f[L%f.length]^L*31+17&255;I+=String.fromCharCode(P)}return p+":"+btoa(I)}function v(n,r){if(!n)return"";try{if(n.indexOf(":")===16){const I=n.substring(0,16),L=new Uint8Array(I.match(/.{2}/g).map($=>parseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(r+I);let B="";for(let $=0;$<P.length;$++){const E=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(E)}return B}const p=atob(n);let w="";for(let I=0;I<p.length;I++){const L=p.charCodeAt(I)^r.charCodeAt(I%r.length);w+=String.fromCharCode(L)}return w}catch{return""}}function u(n,r,f){const p=safeGet(`${n}-${r}-${f}-enc`);return v(p,b)}function l(n,r,f,p){const w=`${n}-${r}-${f}-enc`;p?safeSet(w,y(p,b)):safeRemove(w)}function a(n,r){return u(n,r,"token")}function o(n,r){return u(n,r,"username")}function g(n,r,f){l(n,r,"token",f)}function d(n,r,f){l(n,r,"username",f)}function t(){const n={};for(const r of c())n[r]={readonly:{username:o(r,"readonly"),token:a(r,"readonly")},admin:{username:o(r,"admin"),token:a(r,"admin")}};return n}function e(){c().forEach(n=>{["readonly","admin"].forEach(r=>{["token","username"].forEach(f=>{safeRemove(`${n}-${r}-${f}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function i(n){const r=a(n,"readonly"),f=o(n,"readonly"),p=a(n,"admin"),w=o(n,"admin"),I=v(safeGet(`${n}-token-enc`),b),L=v(safeGet(`${n}-username-enc`),b);return{username:w||f||L,token:p||r||I,readonlyToken:r||I,readonlyUsername:f||L,adminToken:p||I,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{m();const n=document.getElementById("token-management-modal"),r=t();c().forEach(f=>{const p=r[f];document.getElementById(`${f}-readonly-username`).value=p.readonly.username,document.getElementById(`${f}-readonly-token`).value=p.readonly.token,document.getElementById(`${f}-admin-username`).value=p.admin.username,document.getElementById(`${f}-admin-token`).value=p.admin.token,document.getElementById(`${f}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const r=n.target.closest(".token-toggle");if(r){const f=r.dataset.target,p=document.getElementById(f);p.type==="password"?(p.type="text",r.textContent="\u{1F648}"):(p.type="password",r.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=c();n.forEach(p=>{d(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),g(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),d(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),g(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const r={};let f=!1;if(n.forEach(p=>{const w={},I=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),C=document.getElementById(`${p}-admin-token`).value.trim();I&&L&&(w.readonly={username:I,password:L},f=!0),P&&C&&(w.admin={username:P,password:C},f=!0),Object.keys(w).length>0&&(r[p]=w)}),f){n.forEach(p=>{r[p]&&(document.getElementById(`${p}-token-status`).textContent="Verifying...",document.getElementById(`${p}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:r})})).json();w.results?n.forEach(I=>{const L=document.getElementById(`${I}-token-status`);if(!r[I]){L.textContent="";return}const P=w.results[I];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2713 Saved",document.getElementById(`${I}-token-status`).className="token-status success")}):n.forEach(I=>{r[I]&&(document.getElementById(`${I}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${I}-token-status`).className="token-status error")})}catch(p){console.erro
Line truncated
|
||||
|
||||
Current: ${s.currentVersion}
|
||||
New: ${s.updateVersion}
|
||||
Current: ${d.currentVersion}
|
||||
New: ${d.updateVersion}
|
||||
|
||||
`+(s.updateTitle?`${s.updateTitle}
|
||||
`+(d.updateTitle?`${d.updateTitle}
|
||||
|
||||
`:"")+`The DNS server will restart during the update.
|
||||
Proceed?`)){d.textContent=o,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const l=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(v)}`,{method:"POST"})).json();if(!l.success)throw new Error(l.error||"Update failed");if(l.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${l.newVersion}`;const t=l.downloadLink?`
|
||||
Download: ${l.downloadLink}`:"",i=l.instructionsLink?`
|
||||
Instructions: ${l.instructionsLink}`:"";showNotification(`${r.toUpperCase()} update requires manual installation. Current: ${l.previousVersion} \u2192 ${l.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${r.toUpperCase()} updated successfully! ${l.previousVersion} \u2192 ${l.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(p){console.error("DNS update error:",p),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${r.toUpperCase()}: ${p.message}`,"error"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-update"]');if(!v)return;const d=v.id.replace("-update","");SITE.dnsServers[d]&&a(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`
|
||||
Proceed?`)){a.textContent=o,a.disabled=!1,a.title="Update DNS server";return}a.textContent="\u{1F504}",a.title="Updating...";const i=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(l)}`,{method:"POST"})).json();if(!i.success)throw new Error(i.error||"Update failed");if(i.manualUpdateRequired){a.textContent="\u2B06\uFE0F",a.title=`Update available: ${i.newVersion}`;const n=i.downloadLink?`
|
||||
Download: ${i.downloadLink}`:"",r=i.instructionsLink?`
|
||||
Instructions: ${i.instructionsLink}`:"";showNotification(`${u.toUpperCase()} update requires manual installation. Current: ${i.previousVersion} \u2192 ${i.newVersion}. Please update manually on the host machine.`,"warning",8e3),a.disabled=!1;return}a.textContent="\u2705",a.title="Updated successfully!",showNotification(`${u.toUpperCase()} updated successfully! ${i.previousVersion} \u2192 ${i.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{a.textContent=o,a.disabled=!1,a.title="Update DNS server",window.refreshAll()},1e4)}catch(g){console.error("DNS update error:",g),a.textContent="\u274C",a.title="Update failed",showNotification(`Failed to update ${u.toUpperCase()}: ${g.message}`,"error"),setTimeout(()=>{a.textContent=o,a.disabled=!1,a.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-update"]');if(!l)return;const a=l.id.replace("-update","");SITE.dnsServers[a]&&s(a,SITE.dnsServers[a]?.ip)}),injectModal("dns-settings-modal",`
|
||||
<div id="dns-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 id="dns-settings-title">DNS Settings</h3>
|
||||
@@ -366,7 +366,7 @@ Instructions: ${l.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
<button id="dns-settings-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let f=null;function h(r){f=r;const v=SITE.dnsServers[r]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(v.name||r).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=v.ip||"",document.getElementById("dns-edit-port").value=v.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=v.name||"",d.classList.add("show")}async function g(){if(!f)return;const r=document.getElementById("dns-edit-ip").value.trim(),v=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!r){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[f]={ip:r,port:String(v)},d&&(o.dnsServers[f].name=d);try{const s=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();s.success?(SITE.dnsServers[f]=o.dnsServers[f],showNotification(`${f.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(s.error||"Failed to save settings","error")}catch(p){showNotification("Failed to save: "+p.message,"error")}}async function m(){if(f&&confirm(`Remove ${f.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const v=await(await secureFetch("/api/v1/config")).json();v.dnsServers&&delete v.dnsServers[f];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:v.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[f];const p=document.querySelector(`.top [data-app="${f}"]`);p&&p.remove(),showNotification(`${f.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(o.error||"Failed to remove","error")}catch(r){showNotification("Failed to remove: "+r.message,"error")}}function b(){closeModal("dns-settings-modal"),f=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",g),document.getElementById("dns-settings-delete")?.addEventListener("click",m),document.getElementById("dns-settings-modal")?.addEventListener("click",r=>{r.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-settings"]');if(!v)return;const d=v.id.replace("-settings","");SITE.dnsServers[d]&&(r.stopPropagation(),h(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
</div>`);let m=null;function h(u){m=u;const l=SITE.dnsServers[u]||{},a=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(l.name||u).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=l.ip||"",document.getElementById("dns-edit-port").value=l.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=l.name||"",a.classList.add("show")}async function b(){if(!m)return;const u=document.getElementById("dns-edit-ip").value.trim(),l=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,a=document.getElementById("dns-edit-name").value.trim();if(!u){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[m]={ip:u,port:String(l)},a&&(o.dnsServers[m].name=a);try{const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();d.success?(SITE.dnsServers[m]=o.dnsServers[m],showNotification(`${m.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(d.error||"Failed to save settings","error")}catch(g){showNotification("Failed to save: "+g.message,"error")}}async function y(){if(m&&confirm(`Remove ${m.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const l=await(await secureFetch("/api/v1/config")).json();l.dnsServers&&delete l.dnsServers[m];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:l.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[m];const g=document.querySelector(`.top [data-app="${m}"]`);g&&g.remove(),showNotification(`${m.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(o.error||"Failed to remove","error")}catch(u){showNotification("Failed to remove: "+u.message,"error")}}function v(){closeModal("dns-settings-modal"),m=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",b),document.getElementById("dns-settings-delete")?.addEventListener("click",y),document.getElementById("dns-settings-modal")?.addEventListener("click",u=>{u.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",u=>{const l=u.target.closest('[id$="-settings"]');if(!l)return;const a=l.id.replace("-settings","");SITE.dnsServers[a]&&(u.stopPropagation(),h(a))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
<div id="logs-modal" class="logs-modal">
|
||||
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<div class="logs-header">
|
||||
@@ -390,16 +390,16 @@ Instructions: ${l.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let c=null,a=null,f=!1,h=null,g=null,m=!1,b=null,r=null,v=!1,d=null,o=!1;async function p(k,x=25){try{const I=getDnsServerAddr(k),E=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function s(k){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[k]||"var(--fg)"}function e(k){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",k.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(k.raw)}</span>`,x;const I=s(k.rcode),E=k.rcode==="Refused"||k.rcode==="REFUSED";return x.innerHTML=`
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(k.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(k.client)}">${escapeHtml(k.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${E?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(k.domain)}">${escapeHtml(k.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(k.type)}</span>
|
||||
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(k.rcode)}</span>
|
||||
`,x}async function n(){if(v){await B();return}if(m){await T();return}if(f||!c)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(c,k);if(I.error){x.innerHTML=`
|
||||
</div>`);let c=null,s=null,m=!1,h=null,b=null,y=!1,v=null,u=null,l=!1,a=null,o=!1;async function g(E,x=25){try{const S=getDnsServerAddr(E),T=await fetch(`/api/v1/dns/logs?server=${S}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,server:k.server}:{error:k.error||"Failed to fetch logs"}}else return T.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${T.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function d(E){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[E]||"var(--fg)"}function t(E){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",E.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,x;const S=d(E.rcode),T=E.rcode==="Refused"||E.rcode==="REFUSED";return x.innerHTML=`
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(E.client)}">${escapeHtml(E.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${T?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(E.domain)}">${escapeHtml(E.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(E.type)}</span>
|
||||
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(E.rcode)}</span>
|
||||
`,x}async function e(){if(l){await B();return}if(y){await I();return}if(m||!c)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await g(c,E);if(S.error){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(I.error)}</div>
|
||||
<div>${escapeHtml(S.error)}</div>
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span>Time</span>
|
||||
@@ -407,49 +407,49 @@ Instructions: ${l.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
<span>Domain</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
</div>`,I.logs&&I.logs.length>0?I.logs.forEach(E=>{const S=e(E);x.appendChild(S)}):x.innerHTML+=`
|
||||
</div>`,S.logs&&S.logs.length>0?S.logs.forEach(T=>{const k=t(T);x.appendChild(k)}):x.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No DNS queries logged yet
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
</div>`}catch(S){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function l(k){c=k,f=!1,m=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${k.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),n(),a=setInterval(n,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),y(),c=null,m=!1,h=null,g=null,v=!1,b=null,r=null,f=!1}function i(k){d&&y();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),E=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{d=new EventSource(`/api/v1/logs/stream/${k}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),d.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),y();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",_=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${_}</div>
|
||||
Failed to fetch logs: ${escapeHtml(S.message)}
|
||||
</div>`}}function i(E){c=E,m=!1,y=!1;const x=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),T=document.getElementById("logs-pause"),k=document.getElementById("logs-stream");S.textContent=`${E.toUpperCase()} DNS Logs`,T.textContent="\u23F8\uFE0F Pause",T.classList.remove("paused"),k&&(k.style.display="none"),x.classList.add("show"),e(),s=setInterval(e,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),s&&(clearInterval(s),s=null),f(),c=null,y=!1,h=null,b=null,l=!1,v=null,u=null,m=!1}function r(E){a&&f();const x=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),T=document.getElementById("logs-content");s&&(clearInterval(s),s=null);try{a=new EventSource(`/api/v1/logs/stream/${E}`),o=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",S.style.display="none";const k=document.getElementById("logs-title");k.textContent.includes("\u{1F534}")||(k.innerHTML=k.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),a.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),f();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const _=(D.stream||"stdout")==="stderr",N=_?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${_?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${_?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${M}</div>
|
||||
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
|
||||
`,E.appendChild(O),E.scrollTop=E.scrollHeight;E.children.length>500;)E.removeChild(E.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},d.onerror=A=>{console.error("EventSource error:",A),y()}}catch(S){console.error("Failed to start streaming:",S),y()}}function y(){d&&(d.close(),d=null),o=!1;const k=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");k&&(k.classList.remove("active"),k.textContent="\u{1F4E1} Live",k.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),m&&h&&!a&&(a=setInterval(T,DC.POLL.LOGS))}async function u(k,x=100){try{const I=`/api/v1/logs/container/${k}?tail=${x}×tamps=true`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${E.status}: ${E.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=k.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${E}</div>
|
||||
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(k.text)}</div>
|
||||
`,x}async function T(){if(f||!h||!m)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await u(h,k);if(I.error){x.innerHTML=`
|
||||
`,T.appendChild(O),T.scrollTop=T.scrollHeight;T.children.length>500;)T.removeChild(T.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},a.onerror=A=>{console.error("EventSource error:",A),f()}}catch(k){console.error("Failed to start streaming:",k),f()}}function f(){a&&(a.close(),a=null),o=!1;const E=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),S=document.getElementById("logs-title");E&&(E.classList.remove("active"),E.textContent="\u{1F4E1} Live",E.title="Enable real-time streaming"),x&&(x.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),y&&h&&!s&&(s=setInterval(I,DC.POLL.LOGS))}async function p(E,x=100){try{const S=`/api/v1/logs/container/${E}?tail=${x}×tamps=true`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,containerName:k.containerName,containerId:k.containerId}:{error:k.error||"Failed to fetch container logs"}}else return{error:`HTTP ${T.status}: ${T.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function w(E){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.stream==="stderr"?"var(--bad-fg)":"var(--fg)",T=E.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${T}</div>
|
||||
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(E.text)}</div>
|
||||
`,x}async function I(){if(m||!h||!y)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await p(h,E);if(S.error){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(I.error)}</div>
|
||||
<div>${escapeHtml(S.error)}</div>
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex-shrink: 0; width: 80px;">Stream</span>
|
||||
<span style="flex: 1;">Log Output</span>
|
||||
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(E=>{const S=w(E);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=w(T);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available for this container
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
</div>`}catch(S){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function L(k,x){h=k,g=x,m=!0,v=!1,f=!1,y();const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),a=setInterval(T,DC.POLL.LOGS)}async function P(k,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(k)}&tail=${x}`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function C(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.text;let E="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(E="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(E="DEBUG",S="var(--muted)");const D=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${E}</span>`;return x.innerHTML=`
|
||||
Failed to fetch logs: ${escapeHtml(S.message)}
|
||||
</div>`}}function L(E,x){h=E,b=x,y=!0,l=!1,m=!1,f();const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${x} - Container Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),I(),s=setInterval(I,DC.POLL.LOGS)}async function P(E,x=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${x}`,T=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(T.ok){const k=await T.json();return k.success&&k.logs?{logs:k.logs,count:k.count,logPath:k.logPath,totalLines:k.totalLines}:{error:k.error||"Failed to fetch file logs"}}else return{error:(await T.json().catch(()=>({}))).error||`HTTP ${T.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function C(E){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const S=E.text;let T="INFO",k="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(T="ERROR",k="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(T="WARN",k="#f39c12"):S.match(/DEBUG/i)&&(T="DEBUG",k="var(--muted)");const D=`<span style="background: ${k==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${k}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${T}</span>`;return x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${D}</div>
|
||||
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
|
||||
`,x}async function B(){if(f||!b||!v)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await P(b,k);if(I.error){x.innerHTML=`
|
||||
<div style="flex: 1; color: ${k}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div>
|
||||
`,x}async function B(){if(m||!v||!l)return;const E=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const S=await P(v,E);if(S.error){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
|
||||
<div>${escapeHtml(I.error)}</div>
|
||||
<div>${escapeHtml(S.error)}</div>
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex: 1;">Log Output (${I.count} of ${I.totalLines} lines)</span>
|
||||
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(E=>{const S=C(E);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
<span style="flex: 1;">Log Output (${S.count} of ${S.totalLines} lines)</span>
|
||||
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(T=>{const k=C(T);x.appendChild(k)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available in this file
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
</div>`}catch(S){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function $(k,x){b=k,r=x,v=!0,m=!1,f=!1;const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",k=>{const x=k.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&l(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{f=!f;const k=document.getElementById("logs-pause");f?(k.textContent="\u25B6\uFE0F Resume",k.classList.add("paused")):(k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),n())}),document.getElementById("log-lines")?.addEventListener("change",()=>{f||n()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!m||!h||(o?y():i(h))}),document.getElementById("logs-modal")?.addEventListener("click",k=>{k.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",k=>{k.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=l})(),(function(){injectModal("service-edit-modal",`
|
||||
Failed to fetch logs: ${escapeHtml(S.message)}
|
||||
</div>`}}function $(E,x){v=E,u=x,l=!0,y=!1,m=!1;const S=document.getElementById("logs-modal"),T=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");T.textContent=`\u{1F4CB} ${x} - Application Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),s=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const x=E.target.closest('[id$="-logs"]');if(!x)return;const S=x.id.replace("-logs","");SITE.dnsServers[S]&&i(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{m=!m;const E=document.getElementById("logs-pause");m?(E.textContent="\u25B6\uFE0F Resume",E.classList.add("paused")):(E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),e())}),document.getElementById("log-lines")?.addEventListener("change",()=>{m||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!y||!h||(o?f():r(h))}),document.getElementById("logs-modal")?.addEventListener("click",E=>{E.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",E=>{E.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=i})(),(function(){injectModal("service-edit-modal",`
|
||||
<div id="service-edit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3 id="service-edit-title">Edit Service</h3>
|
||||
@@ -805,44 +805,45 @@ Instructions: ${l.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
|
||||
<button id="add-service-create" class="btn-accent">Create Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`)})(),(function(){async function c(m){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(m)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const r=await b.json();if(r.success){const v=document.getElementById("existing-ca-select");return v.innerHTML="",r.cas.length===0?v.innerHTML='<option value="">No CAs found in Caddyfile</option>':(v.innerHTML='<option value="">Select existing CA...</option>',r.cas.forEach(d=>{const o=document.createElement("option");typeof d=="object"?(o.value=d.id,o.textContent=d.displayName||d.name):(o.value=d,o.textContent=d),v.appendChild(o)})),r.data.cas}else throw new Error(r.message)}catch(b){console.error("Error loading CAs:",b);const r=document.getElementById("existing-ca-select");return r.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(m){const{subdomain:b,port:r,ip:v,sslType:d,caName:o,existingCa:p,enableAuth:s,enableCors:e,customHeaders:n,upstreamPath:l,healthCheck:t,timeout:i,tailscaleOnly:y}=m;let u=`${buildDomain(b)} {
|
||||
`;switch(y&&(u+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,u+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),d){case"letsencrypt":break;case"caddy-managed":u+=` tls internal
|
||||
`;break;case"existing-ca":p&&(u+=` tls {
|
||||
ca ${p}
|
||||
</div>`)})(),(function(){async function c(y){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(y)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const u=await v.json();if(u.success){const l=document.getElementById("existing-ca-select");return l.innerHTML="",u.cas.length===0?l.innerHTML='<option value="">No CAs found in Caddyfile</option>':(l.innerHTML='<option value="">Select existing CA...</option>',u.cas.forEach(a=>{const o=document.createElement("option");typeof a=="object"?(o.value=a.id,o.textContent=a.displayName||a.name):(o.value=a,o.textContent=a),l.appendChild(o)})),u.data.cas}else throw new Error(u.message)}catch(v){console.error("Error loading CAs:",v);const u=document.getElementById("existing-ca-select");return u.innerHTML='<option value="">Error loading CAs</option>',[]}}function s(y){const{subdomain:v,port:u,ip:l,sslType:a,caName:o,existingCa:g,enableAuth:d,enableCors:t,customHeaders:e,upstreamPath:i,healthCheck:n,timeout:r,tailscaleOnly:f}=y;let p=`${buildDomain(v)} {
|
||||
`;switch(f&&(p+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,p+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),a){case"letsencrypt":break;case"caddy-managed":p+=` tls internal
|
||||
`;break;case"existing-ca":g&&(p+=` tls {
|
||||
ca ${g}
|
||||
}
|
||||
`);break;case"custom-ca":o&&(u+=` tls {
|
||||
`);break;case"custom-ca":o&&(p+=` tls {
|
||||
ca ${o}
|
||||
}
|
||||
`);break}if(s&&(u+=` basicauth {
|
||||
`);break}if(d&&(p+=` basicauth {
|
||||
admin $2a$14$hashed_password_here
|
||||
}
|
||||
`),e&&(u+=` header {
|
||||
`,u+=` Access-Control-Allow-Origin "*"
|
||||
`,u+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,u+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,u+=` }
|
||||
`),n)try{const w=JSON.parse(n);u+=` header {
|
||||
`,Object.entries(w).forEach(([T,L])=>{u+=` ${T} "${L}"
|
||||
`}),u+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(u+=` health_uri ${t}
|
||||
`),u+=` reverse_proxy ${v}:${r} {
|
||||
`,l&&l!=="/"&&(u+=` rewrite ${l}
|
||||
`),i&&i!==30&&(u+=` transport http {
|
||||
`,u+=` dial_timeout ${i}s
|
||||
`,u+=` response_header_timeout ${i}s
|
||||
`,u+=` }
|
||||
`),u+=` }
|
||||
`,u+=`}
|
||||
`,u}async function f(m,b,r=DC.DEFAULTS.TTL){const v=window.getToken(getPrimaryDnsId(),"admin");if(!v)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(m),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:b,ttl:r,token:v,server:SITE.dnsIp})});if(!o.ok){const s=await o.text();throw new Error(`DNS API Error: ${o.status} - ${s}`)}const p=await o.json();if(!p.success)throw new Error(`DNS Error: ${p.error||"Unknown error"}`);return p}async function h(m){const b={id:m.subdomain,name:m.name,logo:m.logo||`/assets/${m.subdomain}.png`};m.category&&(b.category=m.category),m.containerId&&(b.containerId=m.containerId);try{const r=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!r.ok){const v=await r.json();throw new Error(v.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(r){throw console.error("Failed to add service to config:",r),r}}async function g(m){const b=document.getElementById("service-subdomain-input").value.trim(),r=document.getElementById("service-ip-input").value.trim()||"localhost",v=document.getElementById("service-port-input").value.trim()||"80",d=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${r}:${v}`,config:m})}),o=await d.json();if(!d.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${d.status}`);return o}window.loadExistingCAs=c,window.generateCaddyConfig=a,window.createDnsRecord=f,window.addServiceToConfig=h,window.addToCaddyfile=g})(),(function(){let c=null;function a(r){c=r;const v=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${r.name}`,document.getElementById("edit-service-name").value=r.name,document.getElementById("edit-service-url-display").textContent=r.url||buildServiceUrl(r.id),document.getElementById("edit-service-logo-preview").src=r.logo||`/assets/${r.id}.png`,document.getElementById("edit-subdomain").value=r.id,document.getElementById("edit-port").value=r.port||"",document.getElementById("edit-ip").value=r.ip||"localhost",document.getElementById("edit-tailscale-only").checked=r.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=r.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=r.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),v.classList.add("show")}function f(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const r=document.getElementById("edit-subdomain").value.trim().toLowerCase(),v=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",p=document.getElementById("edit-tailscale-only").checked,s=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!r){showNotification("Subdomain is required","warning");return}const n=c.id,l=[];if(r!==n&&l.push("subdomain"),v&&v!==c.name&&l.push("name"),d&&d!==String(c.port)&&l.push("port"),o!==c.ip&&l.push("ip"),p!==(c.tailscaleOnly||!1)&&l.push("tailscale"),s&&s!==c.logo&&l.push("logo"),e!==(c.category||"")&&l.push("category"),l.length===0){f();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const y=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:n,newSubdomain:r,name:v||c.name,port:d||c.port,ip:o,tailscaleOnly:p,logo:s||void 0,category:e})})).json();if(!y.success)throw new Error(y.error||"Failed to update service");const u=window.APPS.findIndex(w=>w.id===n);u!==-1&&(window.APPS[u]={...window.APPS[u],id:r,name:v||window.APPS[u].name,port:d||window.APPS[u].port,ip:o,tailscaleOnly:p,logo:s||window.APPS[u].logo,category:e||void 0}),f(),window.buildGrid(),window.refreshAll()}catch(i){console.error("Error saving service changes:",i),showNotification(`Error saving changes: ${i.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async r=>{const v=r.target.files[0];if(!v)return;if(!v.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async o=>{const p=o.target.result;if(document.getElementById("edit-service-logo-preview").src=p,document.getElementById("edit-logo-url").value=p,c)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:p})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
`),t&&(p+=` header {
|
||||
`,p+=` Access-Control-Allow-Origin "*"
|
||||
`,p+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,p+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,p+=` }
|
||||
`),e)try{const w=JSON.parse(e);p+=` header {
|
||||
`,Object.entries(w).forEach(([I,L])=>{p+=` ${I} "${L}"
|
||||
`}),p+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(p+=` health_uri ${n}
|
||||
`),p+=` reverse_proxy ${l}:${u} {
|
||||
`,i&&i!=="/"&&(p+=` rewrite ${i}
|
||||
`),r&&r!==30&&(p+=` transport http {
|
||||
`,p+=` dial_timeout ${r}s
|
||||
`,p+=` response_header_timeout ${r}s
|
||||
`,p+=` }
|
||||
`),p+=` }
|
||||
`,p+=`}
|
||||
`,p}async function m(y,v,u=DC.DEFAULTS.TTL){const l=window.getToken(getPrimaryDnsId(),"admin");if(!l)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const a=buildDomain(y),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:a,ip:v,ttl:u,token:l,server:SITE.dnsIp})});if(!o.ok){const d=await o.text();throw new Error(`DNS API Error: ${o.status} - ${d}`)}const g=await o.json();if(!g.success)throw new Error(`DNS Error: ${g.error||"Unknown error"}`);return g}async function h(y){const v={id:y.subdomain,name:y.name,logo:y.logo||`/assets/${y.subdomain}.png`};y.category&&(v.category=y.category),y.containerId&&(v.containerId=y.containerId);try{const u=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!u.ok){const l=await u.json();throw new Error(l.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(u){throw console.error("Failed to add service to config:",u),u}}async function b(y){const v=document.getElementById("service-subdomain-input").value.trim(),u=document.getElementById("service-ip-input").value.trim()||"localhost",l=document.getElementById("service-port-input").value.trim()||"80",a=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${u}:${l}`,config:y})}),o=await a.json();if(!a.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${a.status}`);return o}window.loadExistingCAs=c,window.generateCaddyConfig=s,window.createDnsRecord=m,window.addServiceToConfig=h,window.addToCaddyfile=b})(),(function(){let c=null;function s(u){c=u;const l=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${u.name}`,document.getElementById("edit-service-name").value=u.name,document.getElementById("edit-service-url-display").textContent=u.url||buildServiceUrl(u.id),document.getElementById("edit-service-logo-preview").src=u.logo||`/assets/${u.id}.png`,document.getElementById("edit-subdomain").value=u.id,document.getElementById("edit-port").value=u.port||"",document.getElementById("edit-ip").value=u.ip||"localhost",document.getElementById("edit-tailscale-only").checked=u.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=u.logo||"";const a=document.getElementById("edit-service-category");a&&(a.dataset.current=u.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),l.classList.add("show")}function m(){closeModal("service-edit-modal"),c=null}async function h(){if(!c)return;const u=document.getElementById("edit-subdomain").value.trim().toLowerCase(),l=document.getElementById("edit-service-name").value.trim(),a=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",g=document.getElementById("edit-tailscale-only").checked,d=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!u){showNotification("Subdomain is required","warning");return}const e=c.id,i=[];if(u!==e&&i.push("subdomain"),l&&l!==c.name&&i.push("name"),a&&a!==String(c.port)&&i.push("port"),o!==c.ip&&i.push("ip"),g!==(c.tailscaleOnly||!1)&&i.push("tailscale"),d&&d!==c.logo&&i.push("logo"),t!==(c.category||"")&&i.push("category"),i.length===0){m();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const f=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:u,name:l||c.name,port:a||c.port,ip:o,tailscaleOnly:g,logo:d||void 0,category:t})})).json();if(!f.success)throw new Error(f.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===e);p!==-1&&(window.APPS[p]={...window.APPS[p],id:u,name:l||window.APPS[p].name,port:a||window.APPS[p].port,ip:o,tailscaleOnly:g,logo:d||window.APPS[p].logo,category:t||void 0}),m(),window.buildGrid(),window.refreshAll()}catch(r){console.error("Error saving service changes:",r),showNotification(`Error saving changes: ${r.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async u=>{const l=u.target.files[0];if(!l)return;if(!l.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=async o=>{const g=o.target.result;if(document.getElementById("edit-service-logo-preview").src=g,document.getElementById("edit-logo-url").value=g,c)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${c.id}.png`,data:g})})).json();t.success&&t.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
|
||||
This will:
|
||||
1. Pull the latest image
|
||||
2. Stop the container
|
||||
3. Recreate with same settings
|
||||
|
||||
The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${r}/update`,{method:"POST"})).json();if(e.success){const n=window.APPS.find(l=>l.id===d);n&&e.newContainerId&&(n.containerId=e.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${v} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(s){console.error("Update error:",s),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=p,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${v}: ${s.message}`,"error")}}async function b(r,v){const d=window.APPS.find(u=>u.id===r),o=d?buildDomain(d.id):null,p=d?.containerId,s=await g(v||r,p,d?.containerId);if(s===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(s&&p)try{const u=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${u.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(u){console.error("App removal error:",u)}else if(s&&o){try{const u=d?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(u)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(u){e.dns=u.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(u){e.caddy=u.message}}const n=window.APPS.findIndex(u=>u.id===r);n>-1&&(window.APPS.splice(n,1),e.dashboard=!0);try{const u=safeGetJSON("custom-apps",[]),w=u.findIndex(T=>T.id===r);w>-1&&(u.splice(w,1),safeSet("custom-apps",JSON.stringify(u)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(u){e.service=u.message}window.buildGrid(),window.refreshAll();let l=!1,t=[];e.dashboard||(l=!0,t.push("\u2717 Failed to remove from dashboard"));const i=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=u=>!u||i.some(w=>u.toLowerCase().includes(w.toLowerCase()));e.container&&!y(e.container)&&(l=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(l=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(l=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(l=!0,t.push(`\u26A0 Service File: ${e.service}`)),l&&showNotification(`Error deleting "${v||r}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=g,window.updateContainer=m,window.deleteService=b})(),(function(){function c(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function a(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function f(){const e=document.getElementById("service-subdomain-input").value||"subdomain",n=document.getElementById("service-ip-input").value||h.lan||"localhost",l=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,i=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,u=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${n}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const k={subdomain:e,port:l,ip:n,sslType:t,caName:i,existingCa:y,enableAuth:u,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:C},x=window.generateCaddyConfig(k),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function g(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const i=await t.json();i.lan&&(h.lan=i.lan),i.tailscale&&(h.tailscale=i.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),n=document.getElementById("quick-ip-tailscale");e&&(h.lan?(e.dataset.ip=h.lan,e.textContent=`LAN (${h.lan})`,e.title=`LAN IP: ${h.lan}`):e
Line truncated
|
||||
The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${u}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(i=>i.id===a);e&&t.newContainerId&&(e.containerId=t.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=g,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${l} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(d){console.error("Update error:",d),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=g,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${l}: ${d.message}`,"error")}}async function v(u,l){const a=window.APPS.find(p=>p.id===u),o=a?buildDomain(a.id):null,g=a?.containerId,d=await b(l||u,g,a?.containerId);if(d===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(d&&g)try{const p=new URLSearchParams({containerId:a.containerId,subdomain:a.id,ip:a.ip||"localhost",deleteContainer:"true"}),I=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(a.id)}?${p.toString()}`,{method:"DELETE"})).json();I.success?t={...t,...I.results,dashboard:!1}:console.error("App removal failed:",I.error)}catch(p){console.error("App removal error:",p)}else if(d&&o){try{const p=a?.ip||"localhost",I=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=I.success?"deleted":I.error||"failed"}catch(p){t.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();t.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){t.caddy=p.message}}const e=window.APPS.findIndex(p=>p.id===u);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(I=>I.id===u);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(u)}`,{method:"DELETE"})).json();t.service=w.success?"removed":w.error||"failed"}catch(p){t.service=p.message}window.buildGrid(),window.refreshAll();let i=!1,n=[];t.dashboard||(i=!0,n.push("\u2717 Failed to remove from dashboard"));const r=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],f=p=>!p||r.some(w=>p.toLowerCase().includes(w.toLowerCase()));t.container&&!f(t.container)&&(i=!0,n.push(`\u26A0 Container: ${t.container}`)),t.dns&&!f(t.dns)&&(i=!0,n.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!f(t.caddy)&&(i=!0,n.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!f(t.service)&&(i=!0,n.push(`\u26A0 Service File: ${t.service}`)),i&&showNotification(`Error deleting "${l||u}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=s,window.showDeleteModal=b,window.updateContainer=y,window.deleteService=v})(),(function(){function c(t){return t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function s(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function m(){const t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||h.lan||"localhost",i=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,r=document.getElementById("ca-name-input").value||"sami-ca",f=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,I=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(t)} \u2192 ${e}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(t));const E={subdomain:t,port:i,ip:e,sslType:n,caName:r,existingCa:f,enableAuth:p,enableCors:w,customHeaders:I,upstreamPath:L,healthCheck:P,timeout:C},x=window.generateCaddyConfig(E),S=document.getElementById("caddy-config-preview");S&&(S.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function b(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const r=await n.json();r.lan&&(h.lan=r.lan),r.tailscale&&(h.tailscale=r.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(h.lan?(t.dataset.ip=h.lan,t.textContent=`LAN (${h.lan})`,t.title=`LAN IP: ${h.lan}`):t
Line truncated
|
||||
<span style="color: #4caf50;">\u2713 Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
|
||||
`,y.disabled=!1):w.installed?(i.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',y.disabled=!0):(i.innerHTML='<span style="color: var(--muted);">Not available</span>',y.disabled=!0)}catch{i.innerHTML='<span style="color: var(--muted);">Could not check</span>',y.disabled=!0}y.checked=!1,f()}function r(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),l=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),i=document.getElementById("tab-local"),y=document.getElementById("tab-external");function u(){e.checked?(l.style.display="grid",t.style.display="none",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(l.style.display="none",t.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function v(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),l=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=c(e.value);!t&&n&&(n.value=L),l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()}),n?.addEventListener("input",()=>{t=n.value!==c(e?.value||"");const L=n.value.trim()||c(e?.value||"");l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()});const i=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;i?.addEventListener("input",()=>{const L=c(i.value);!T&&y&&(y.value=L);const P=y?.value||L;u&&(u.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),y?.addEventListener("input",()=>{T=y.value!==c(i?.value||"");const L=y.value.trim()||c(i?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function d(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),l=(document.getElementById("external-service-subdomain").value.trim()||c(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),i=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!l){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(l);try{const B={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const E={subdomain:l,externalUrl:n,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(E){B.caddy=E.message}const $={id:l,name:e,url:`https://${C}`,externalUrl:n,logo:t||i||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const k=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(E=>!k.includes(E.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(E){console.warn("Failed to save to services.json:",E)}window.buildGrid(),window.refreshAll(),o();const I=[`External service "${e}" added!`];y&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
`,f.disabled=!1):w.installed?(r.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',f.disabled=!0):(r.innerHTML='<span style="color: var(--muted);">Not available</span>',f.disabled=!0)}catch{r.innerHTML='<span style="color: var(--muted);">Could not check</span>',f.disabled=!0}f.checked=!1,m()}function u(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),i=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),f=document.getElementById("tab-external");function p(){t.checked?(i.style.display="grid",n.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),f&&(f.style.background="transparent",f.style.color="var(--muted)")):(i.style.display="none",n.style.display="block",f&&(f.style.background="var(--accent)",f.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}t?.addEventListener("change",p),e?.addEventListener("change",p)}function l(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),i=document.getElementById("subdomain-preview");let n=!1;t?.addEventListener("input",()=>{const L=c(t.value);!n&&e&&(e.value=L),i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),m()}),e?.addEventListener("input",()=>{n=e.value!==c(t?.value||"");const L=e.value.trim()||c(t?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),m()});const r=document.getElementById("external-service-name"),f=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let I=!1;r?.addEventListener("input",()=>{const L=c(r.value);!I&&f&&(f.value=L);const P=f?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),f?.addEventListener("input",()=>{I=f.value!==c(r?.value||"");const L=f.value.trim()||c(r?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function a(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),i=(document.getElementById("external-service-subdomain").value.trim()||c(t)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),f=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",I=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!t||!e){showNotification("Please fill in Name and External URL","warning");return}if(!i){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!e.startsWith("http://")&&!e.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(i);try{const B={dns:null,caddy:null,dashboard:!1};if(f)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(k){B.dns=k.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const T={subdomain:i,externalUrl:e,preserveHost:I,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(T)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(T){B.caddy=T.message}const $={id:i,name:t,url:`https://${C}`,externalUrl:e,logo:n||r||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const E=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(T=>!E.includes(T.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(T){console.warn("Failed to save to services.json:",T)}window.buildGrid(),window.refreshAll(),o();const S=[`External service "${t}" added!`];f&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&S.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),S.push(`Access at: https://${C}`),showNotification(S.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI
Line truncated
|
||||
Vendored
+288
-249
@@ -90,44 +90,44 @@
|
||||
<button id="logo-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("logo-modal"),S=document.getElementById("logo-preview-dark"),O=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),T=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),I=DC.NAME;let C=null,j=null,M=null,D="left",x=I;N?.addEventListener("change",()=>{N.checked?(R.style.display="none",z.style.display="",C=null,j=null):(R.style.display="flex",z.style.display="none",M=null)});function A(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=a=>e(a.target.result),n.readAsDataURL(t)}P?.addEventListener("change",t=>{A(t.target.files[0],e=>{C=e,S.src=e,E.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{A(t.target.files[0],e=>{j=e,O.src=e,E.textContent="New light logo ready to save"})}),L?.addEventListener("change",t=>{A(t.target.files[0],e=>{M=e,S.src=e,O.src=e,E.textContent="New logo ready to save (both themes)"})});function y(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){x=t||I,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function b(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,S.src=e.customLogoDark),e.customLogoLight&&(T.src=e.customLogoLight,O.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,T.src=e.customLogo,S.src=e.customLogo,O.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(D=e.position,y(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{D=t.dataset.pos,y(D)})}),document.getElementById("brand")?.addEventListener("click",()=>{C=null,j=null,M=null,P&&(P.value=""),g&&(g.value=""),L&&(L.value=""),N&&(N.checked=!1),R.style.display="flex",z.style.display="none",S.src=w.src,O.src=T.src;const t=w.src.includes("custom-logo")||T.src.includes("custom-logo");E.textContent=t?"Using custom logo":"Using default logos",y(D),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=B.value.trim()||I,e={position:D,dashboardTitle:t};N?.checked&&M?(e.dataDark=M,e.dataLight=M):(C&&(e.dataDark=C),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const a=await n.json(),l="?t="+Date.now();a.pathDark&&(w.src=a.pathDark+l,S.src=a.pathDark+l),a.pathLight&&(T.src=a.pathLight+l,O.src=a.pathLight+l),y(D),m(t),h.classList.remove("show")}else{const a=await n.json();showNotification("Failed to save: "+a.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
</div>`);const f=document.getElementById("logo-modal"),I=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),M=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),B=document.getElementById("logo-upload-single"),x=document.querySelector("#brand .brand-logo-dark"),$=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),T=document.getElementById("dashboard-title"),S=DC.NAME;let L=null,j=null,C=null,N="left",R=S;M?.addEventListener("change",()=>{M.checked?(P.style.display="none",z.style.display="",L=null,j=null):(P.style.display="flex",z.style.display="none",C=null)});function U(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{U(t.target.files[0],e=>{L=e,I.src=e,E.textContent="New dark logo ready to save"})}),v?.addEventListener("change",t=>{U(t.target.files[0],e=>{j=e,A.src=e,E.textContent="New light logo ready to save"})}),B?.addEventListener("change",t=>{U(t.target.files[0],e=>{C=e,I.src=e,A.src=e,E.textContent="New logo ready to save (both themes)"})});function u(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){R=t||S,document.title=R;const e=document.querySelector(".dashboard-title");e&&(e.textContent=R)}async function h(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(x.src=e.customLogoDark,I.src=e.customLogoDark),e.customLogoLight&&($.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(x.src=e.customLogo,$.src=e.customLogo,I.src=e.customLogo,A.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(N=e.position,u(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{N=t.dataset.pos,u(N)})}),document.getElementById("brand")?.addEventListener("click",()=>{L=null,j=null,C=null,D&&(D.value=""),v&&(v.value=""),B&&(B.value=""),M&&(M.checked=!1),P.style.display="flex",z.style.display="none",I.src=x.src,A.src=$.src;const t=x.src.includes("custom-logo")||$.src.includes("custom-logo");E.textContent=t?"Using custom logo":"Using default logos",u(N),T.value=R,f.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=T.value.trim()||S,e={position:N,dashboardTitle:t};M?.checked&&C?(e.dataDark=C,e.dataLight=C):(L&&(e.dataDark=L),j&&(e.dataLight=j));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(x.src=o.pathDark+i,I.src=o.pathDark+i),o.pathLight&&($.src=o.pathLight+i,A.src=o.pathLight+i),u(N),m(t),f.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",T.src="/assets/dashcaddy-logo-light.png",S.src="/assets/dashcaddy-logo-dark.png",O.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",C=null,j=null,M=null,B.value=I,m(I),D="left",y("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),l=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),l&&(l.textContent="Using DashCaddy favicon"),r=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const p=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),d=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(d.rel="icon",d.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(d));async function f(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(d.href=e.customFavicon+"?t="+Date.now(),p.src=e.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const n=new FileReader;n.onload=a=>{r=a.target.result,p.src=r,u.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(t.ok){const e=await t.json();d.href=e.path+"?t="+Date.now(),p.src=e.path+"?t="+Date.now(),u.textContent="Using custom favicon",r=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),f(),b();const v=document.getElementById("settings-timezone");v&&(new MutationObserver(()=>{h.classList.contains("show")&&v.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(v,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=v.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const a=await n.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(h,S){const O=Intl.supportedValuesOf("timeZone"),E=S||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of O){const R=document.createElement("option");R.value=N,R.textContent=N.replace(/_/g," "),N===E&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",S=null;async function O(){try{const A=await fetch("/api/v1/config");if(A.ok&&(S=await A.json(),S&&S.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(A){console.warn("Could not fetch server config, checking localStorage fallback:",A.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}O();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function N(x){document.querySelectorAll(".setup-step").forEach(y=>{y.style.display="none"});const A=document.getElementById(x);A&&(A.style.display="block")}function R(){const x=document.getElementById("setup-summary-content");if(!x)return;let A='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const m=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",u=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;A+=`
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(x.src="/assets/dashcaddy-logo-dark.png",$.src="/assets/dashcaddy-logo-light.png",I.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",L=null,j=null,C=null,T.value=S,m(S),N="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),d=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(f,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),H=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let d=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function b(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),H.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}r?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const a=new FileReader;a.onload=o=>{d=o.target.result,g.src=d,H.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(t.ok){const e=await t.json();c.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),H.textContent="Using custom favicon",d=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),b(),h();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{f.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(f,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(f,I){const A=Intl.supportedValuesOf("timeZone"),E=I||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f.innerHTML="";for(const M of A){const P=document.createElement("option");P.value=M,P.textContent=M.replace(/_/g," "),M===E&&(P.selected=!0),f.appendChild(P)}},(function(){let f="homelab",I=null;async function A(){try{const m=await fetch("/api/v1/config");if(m.ok&&(I=await m.json(),I&&I.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(m){console.warn("Could not fetch server config, checking localStorage fallback:",m.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function M(u){document.querySelectorAll(".setup-step").forEach(h=>{h.style.display="none"});const m=document.getElementById(u);m&&(m.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let m='<div style="display: grid; gap: 20px;">';if(f==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",H=document.getElementById("setup-ca-name")?.value?.trim()||"",r=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;m+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>TLD:</strong> ${m}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${b}</div>
|
||||
<div><strong>DNS Server:</strong> ${p}:${u}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${m}, https://nextcloud${m}</div>
|
||||
<div><strong>TLD:</strong> ${g}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${H}</div>
|
||||
<div><strong>DNS Server:</strong> ${r}:${c}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${g}, https://nextcloud${g}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(h==="simple"){const m=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";A+=`
|
||||
`}else if(f==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";m+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Access Method:</strong> IP:Port only</div>
|
||||
<div><strong>Default IP:</strong> ${m}</div>
|
||||
<div><strong>Default IP:</strong> ${g}</div>
|
||||
<div><strong>SSL:</strong> None (HTTP only)</div>
|
||||
<div><strong>Example URLs:</strong> http://${m}:8080, http://${m}:3000</div>
|
||||
<div><strong>Example URLs:</strong> http://${g}:8080, http://${g}:3000</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(h==="public"){const m=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u=p==="subdirectory"?`https://${m}/sonarr, https://${m}/grafana`:`https://sonarr.${m}, https://grafana.${m}`;A+=`
|
||||
`}else if(f==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",H=document.getElementById("setup-public-email")?.value?.trim()||"",r=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=r==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;m+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Domain:</strong> ${m}</div>
|
||||
<div><strong>Domain:</strong> ${g}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${b}</div>
|
||||
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${u}</div>
|
||||
<div><strong>Email:</strong> ${H}</div>
|
||||
<div><strong>Routing:</strong> ${r==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${c}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}const y=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";A+=`
|
||||
`}const h=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";m+=`
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${y.replace(/_/g," ")}</div>
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${h.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,A+="</div>",x.innerHTML=A,N("setup-step-summary")}async function z(x){try{const A=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return A.ok?(await A.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${A.status}`),{function:"saveConfigToServer"}),!1)}catch(A){return errorHandler.logError("[SetupWizard] Save Config",A,{function:"saveConfigToServer"}),!1}}async function P(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:b,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const A=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const y=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",m=A?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${y}. Settings saved to: ${m}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(x){x.preventDefault();const A=document.querySelector('input[name="config-type"]:checked');A&&(h=A.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(x){const A=x.target.value||".home",y=document.getElementById("tld-preview"),m=document.getElementById("tld-preview-2");y&&(y.textContent=A),m&&(m.textContent=A)});const T=document.getElementById("setup-homelab-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const A=document.getElementById("setup-tld")?.value?.trim()||"",y=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!A||!A.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!y){showNotification("Please enter a Certificate Authority name","warning");return}if(!m){showNotification("Please enter your DNS server IP address","warning");return}R()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-simple-next");I&&(I.onclick=function(x){x.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var A=document.getElementById("dns-requirement-note");A&&(A.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const C=document.getElementById("setup-public-back");C&&(C.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const A=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"";if(!A){showNotification("Please enter your domain name","warning");return}if(!y||!y.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela
Line truncated
|
||||
`,m+="</div>",u.innerHTML=m,M("setup-step-summary")}async function z(u){try{const m=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return m.ok?(await m.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${m.status}`),{function:"saveConfigToServer"}),!1)}catch(m){return errorHandler.logError("[SetupWizard] Save Config",m,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:f,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(f==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const H=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:H,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else f==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):f==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const m=await z(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const h=f==="homelab"?"Professional Home Lab":f==="simple"?"Simple Setup":"Public Server",g=m?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${h}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(u){u.preventDefault();const m=document.querySelector('input[name="config-type"]:checked');m&&(f=m.value),M(f==="homelab"?"setup-step-homelab":f==="simple"?"setup-step-simple":f==="public"?"setup-step-public":"setup-step-homelab")});const B=document.getElementById("setup-skip");B&&(B.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const x=document.getElementById("setup-tld");x&&(x.oninput=function(u){const m=u.target.value||".home",h=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");h&&(h.textContent=m),g&&(g.textContent=m)});const $=document.getElementById("setup-homelab-back");$&&($.onclick=function(u){u.preventDefault(),M("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-tld")?.value?.trim()||"",h=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!m||!m.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!h){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const T=document.getElementById("setup-simple-back");T&&(T.onclick=function(u){u.preventDefault(),M("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var m=document.getElementById("dns-requirement-note");m&&(m.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const L=document.getElementById("setup-public-back");L&&(L.onclick=function(u){u.preventDefault(),M("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-public-domain")?.value?.trim()||"",h=document.getElementById("setup-public-email")?.value?.trim()||"";if(!m){showNotification("Please enter your domain name","warning");return}if(!h||!h.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(u){u.preventDefault(),f==="homelab"?M("setup-step-homela
Line truncated
|
||||
<div class="app-selector-content">
|
||||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||||
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const S="custom-apps";let O=null,E=null;const N=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function z(){try{const d=await(await fetch("/api/v1/apps/templates")).json();if(d.success)return O=d.templates,E=d.categories,!0}catch(s){h.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function P(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(d){return h.logError("[AppSelector] Check Port",d,{function:"checkPortAvailability"}),{available:!0}}}async function g(s){try{const r=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(r.success)return r.suggestedPort}catch(d){h.logError("[AppSelector] Get Suggested Port",d,{function:"getSuggestedPort"})}return s}async function L(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!O&&!await z()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const s={};for(const[r,f]of Object.entries(O)){const v=f.category||"Other";s[v]||(s[v]=[]),s[v].push({id:r,...f})}const d=E?Object.keys(E):Object.keys(s).sort();for(const r of d){const f=s[r];if(!f||f.length===0)continue;f.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const v=document.createElement("div");v.className="app-category-header";const t=E?.[r]||{};v.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(r)}`,t.color&&(v.style.borderBottomColor=t.color),R.appendChild(v),f.forEach(e=>{const n=document.createElement("div");n.className="app-option";const a=e.isDashboardWidget,l=a&&safeGet("widget-"+e.id+"-enabled")!=="false",o=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${l?"#2ecc7130":"#e74c3c30"}; color: ${l?"#2ecc71":"#e74c3c"}; font-weight: 600;">${l?"ON":"OFF"}</div>`:"",i=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
|
||||
</div>`);const I="custom-apps";let A=null,E=null;const M=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return A=c.templates,E=c.categories,!0}catch(r){f.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function D(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return f.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(r){try{const d=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){f.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function B(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await z()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[d,b]of Object.entries(A)){const y=b.category||"Other";r[y]||(r[y]=[]),r[y].push({id:d,...b})}const c=E?Object.keys(E):Object.keys(r).sort();for(const d of c){const b=r[d];if(!b||b.length===0)continue;b.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const t=E?.[d]||{};y.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(d)}`,t.color&&(y.style.borderBottomColor=t.color),P.appendChild(y),b.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",s=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
|
||||
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
||||
${o}${i}
|
||||
`,a?n.onclick=()=>w(e,n):n.onclick=()=>T(e),R.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function w(s,d){const r="widget-"+s.id+"-enabled",v=!(safeGet(r)!=="false");safeSet(r,String(v));const t=s.widgetSelector;if(t){const n=document.querySelector(t);n&&(n.style.display=v?"":"none")}const e=d.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=v?"ON":"OFF",e.style.background=v?"#2ecc7130":"#e74c3c30",e.style.color=v?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${v?"enabled":"disabled"}`,"success",2e3)}async function T(s){const d=document.getElementById("app-deploy-modal"),r=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),v=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${s.name} container:
|
||||
${n}${s}
|
||||
`,o?a.onclick=()=>x(e,a):a.onclick=()=>$(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function x(r,c){const d="widget-"+r.id+"-enabled",y=!(safeGet(d)!=="false");safeSet(d,String(y));const t=r.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function $(r){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),b=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${r.name} container:
|
||||
|
||||
Container: ${V.name}
|
||||
Status: ${V.status}
|
||||
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
|
||||
Would you like to use this existing container?
|
||||
|
||||
Click OK to configure DNS/Caddy for the existing container.
|
||||
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}r.textContent=`Deploy ${s.name}`;const l=s.subdomain||s.id.replace(/-/g,"");f.value=l;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=s.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">ⓘ '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),$=document.querySelector(`input[name="dns-type"][value="${i}"]`),H=document.querySelector(`input[name="ssl-type"][value="${c}"]`);$?$.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,H?H.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),F=q?.querySelector("div");if(q&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(F.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const _=s.mediaMount?.containerPath,W=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}d.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");b.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const W=r.subpathSupport||"strip";W==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">ⓘ '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),p=document.querySelector(`input[name="dns-type"][value="${s}"]`),w=document.querySelector(`input[name="ssl-type"][value="${l}"]`);p?p.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,w?w.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const O=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){W.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const W=r.mediaMount?.containerPath,G=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(W&&V.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
|
||||
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
||||
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
|
||||
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await P(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
||||
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
|
||||
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=`
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const W=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await D(W);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
|
||||
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
|
||||
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?o.innerHTML=`
|
||||
<span style="color: #4caf50;">Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
|
||||
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=f.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}v.textContent=K}f.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),d.classList.add("show"),d.dataset.appTemplate=JSON.stringify(s)}async function k(s){const d=s.appTemplate,r=safeGetJSON(S,[]),f=d._useExisting&&d._existingContainer,v=r.find(t=>t.id===s.subdomain);if(!(v&&!f&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(v){const t=r.indexOf(v);r.splice(t,1),safeSet(S,JSON.stringify(r))}if(f)s.port=d._existingContainer.primaryPort;else{const t=s.port||d.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const n=await g(d.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
|
||||
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=b.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}y.textContent=K}b.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),M.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function k(r){const c=r.appTemplate,d=safeGetJSON(I,[]),b=c._useExisting&&c._existingContainer,y=d.find(t=>t.id===r.subdomain);if(!(y&&!b&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(y){const t=d.indexOf(y);d.splice(t,1),safeSet(I,JSON.stringify(d))}if(b)r.port=c._existingContainer.primaryPort;else{const t=r.port||c.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await v(c.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
|
||||
Would you like to use port ${n} instead?`))s.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${d.name} with existing container...`:`Deploying ${d.name}...`,"info",0);try{const t={appId:d.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||d.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};f&&(t.config.useExisting=!0,t.config.existingContainerId=d._existingContainer.id,t.config.existingPort=d._existingContainer.primaryPort,!s.port&&d._existingContainer.primaryPort&&(t.config.port=d._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(n.success){const a={id:s.subdomain,name:d.name,logo:`/assets/${d.id}.png`,containerId:n.containerId,url:n.url,ip:s.ip,appTemplate:d.id,tailscaleOnly:s.tailscaleOnly||!1};r.push(a),safeSet(S,JSON.stringify(r)),window.APPS&&!window.APPS.some(o=>o.id===d.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let l=n.usedExisting?`${d.name} configured with existing container!
|
||||
URL: ${n.url}`:`${d.name} deployed successfully!
|
||||
URL: ${n.url}`;n.warning&&(l+=`
|
||||
Would you like to use port ${a} instead?`))r.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(b?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const t={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};b&&(t.config.useExisting=!0,t.config.existingContainerId=c._existingContainer.id,t.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(t.config.port=c._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:a.containerId,url:a.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};d.push(o),safeSet(I,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${c.name} configured with existing container!
|
||||
URL: ${a.url}`:`${c.name} deployed successfully!
|
||||
URL: ${a.url}`;a.warning&&(i+=`
|
||||
|
||||
\u26A0 Warning: ${n.warning}`),showNotification(l,"success",8e3),delete d._useExisting,delete d._existingContainer,n.url&&n.url.startsWith("https://")&&B(n.url,d.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${d.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${d.name}: ${t.message}`,"error",8e3)}}}async function B(s,d){showNotification(`\u23F3 Generating SSL certificate for ${d}...`,"warning",6e4);let r=0;const f=12,v=async()=>{r++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${d} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return r<f?setTimeout(v,5e3):showNotification(`\u26A0\uFE0F ${d} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(v,3e3)}function I(){safeGetJSON(S,[]).forEach(d=>{window.APPS.some(r=>r.id===d.id)||window.APPS.push(d)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const C=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(C.dataset.appTemplate),d=document.getElementById("deploy-media-path").value.trim(),r=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(v=>{r.push({hostPath:v.value.trim(),containerPath:v.dataset.containerPath})});const f={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:d||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:r.length>0?r:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!d){showNotification("Please enter a media library path for this application","warning");return}C.classList.remove("show"),k(f)}),wireModal(C);const j=document.getElementById("folder-browser-modal"),M=document.getElementById("folder-browser-path"),D=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),A=document.getElementById("folder-browser-selected-list");let y="",m=[],b=null;window.openFolderBrowser=function(s){b=s,m=s.value.split(",").map(d=>d.trim()).filter(d=>d),y="",u(),p(""),j.classList.add("show")};async function p(s){M.textContent=s||"Select a drive...",D.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const r=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!r.success){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(r.error)}</div>`;return}y=r.path||"",M.textContent=y||"Select a drive...";let f="";r.parent&&r.parent!==r.path&&(f+=`<div class="folder-item" data-path="${escapeHtml(r.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,a.url&&a.url.startsWith("https://")&&T(a.url,c.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){f.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${t.message}`,"error",8e3)}}}async function T(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const b=12,y=async()=>{d++;try{const t=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<b?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(I,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{B(),M.classList.add("show")}),wireModal(M,document.getElementById("app-selector-cancel"));const L=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(L.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{d.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const b={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:d.length>0?d:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!b.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}L.classList.remove("show"),k(b)}),wireModal(L);const j=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),N=document.getElementById("folder-browser-list"),R=document.getElementById("folder-browser-selected"),U=document.getElementById("folder-browser-selected-list");let u="",m=[],h=null;window.openFolderBrowser=function(r){h=r,m=r.value.split(",").map(c=>c.trim()).filter(c=>c),u="",H(),g(""),j.classList.add("show")};async function g(r){C.textContent=r||"Select a drive...",N.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const d=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!d.success){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}u=d.path||"",C.textContent=u||"Select a drive...";let b="";d.parent&&d.parent!==d.path&&(b+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
||||
<span style="color: var(--muted);">.. Parent Directory</span>
|
||||
</div>`),r.items.length===0&&!r.parent?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':r.items.length===0?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':r.items.forEach(v=>{const t=v.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(v.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<div class="folder-item" data-path="${escapeHtml(v.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
|
||||
</div>`),d.items.length===0&&!d.parent?b+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':d.items.length===0?b+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(y=>{const t=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(y.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";b+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
|
||||
<span style="font-size: 1.2rem;">${t}</span>
|
||||
<span style="flex: 1;">${escapeHtml(v.name)}</span>
|
||||
<span style="flex: 1;">${escapeHtml(y.name)}</span>
|
||||
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
||||
</div>`}),D.innerHTML=f,D.querySelectorAll(".folder-item").forEach(v=>{v.addEventListener("click",()=>{p(v.dataset.path)}),v.addEventListener("mouseenter",()=>{v.style.background="var(--card-bg)"}),v.addEventListener("mouseleave",()=>{const t=m.includes(v.dataset.path);v.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(d){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(d.message)}</div>`}}function u(){if(m.length===0){x.style.display="none";return}x.style.display="block",A.innerHTML=m.map(s=>`
|
||||
</div>`}),N.innerHTML=b,N.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{g(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const t=m.includes(y.dataset.path);y.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function H(){if(m.length===0){R.style.display="none";return}R.style.display="block",U.innerHTML=m.map(r=>`
|
||||
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
${escapeHtml(s)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
${escapeHtml(r)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
</span>
|
||||
`).join("")}window.removeSelectedFolder=function(s){m=m.filter(d=>d!==s),u(),p(y)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{y&&!m.includes(y)&&(m.push(y),u(),p(y))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{b&&(b.value=m.join(", ")),j.classList.remove("show")}),I()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
`).join("")}window.removeSelectedFolder=function(r){m=m.filter(c=>c!==r),H(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!m.includes(u)&&(m.push(u),H(),g(u))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{h&&(h.value=m.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
|
||||
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
|
||||
|
||||
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let h=null,S=null,O=null,E=1,N=!1;const R=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(R,z);async function L(){try{const y=await fetch("/api/v1/recipes/templates"),m=await y.json();if(m.success)return h=m.templates,S=m.categories,!0;if(y.status===403)return N=!1,!1}catch(y){console.warn("Failed to fetch recipe templates:",y.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(y){await w();let m;if(N&&h?m=h:m=T(),!m||m.length===0)return;const b=document.createElement("div");b.className="app-category-header",b.innerHTML="\u{1F9EA} Recipes",b.style.borderBottomColor="#8e44ad",y.appendChild(b);const p=Array.isArray(m)?m:Object.values(m);p.sort((u,s)=>(s.popularity||0)-(u.popularity||0));for(const u of p){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const d=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${u.componentCount||u.components?.length||"?"} apps</div>`,r=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
|
||||
${r}
|
||||
<div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(u.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(u.description||"")}</div>
|
||||
</div>`);let f=null,I=null,A=null,E=1,M=!1;const P=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(P,z);async function B(){try{const u=await fetch("/api/v1/recipes/templates"),m=await u.json();if(m.success)return f=m.templates,I=m.categories,!0;if(u.status===403)return M=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function x(){try{M=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{M=!1}return M}window.renderRecipeCards=async function(u){await x();let m;if(M&&f?m=f:m=$(),!m||m.length===0)return;const h=document.createElement("div");h.className="app-category-header",h.innerHTML="\u{1F9EA} Recipes",h.style.borderBottomColor="#8e44ad",u.appendChild(h);const g=Array.isArray(m)?m:Object.values(m);g.sort((H,r)=>(r.popularity||0)-(H.popularity||0));for(const H of g){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${H.componentCount||H.components?.length||"?"} apps</div>`,d=M?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
|
||||
${d}
|
||||
`,s.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(u)},y.appendChild(s)}};function T(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(y){O=y,E=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${y.name}`,B(),I(),R.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(y=>{const m=parseInt(y.dataset.step);y.classList.toggle("active",m===E),y.classList.toggle("completed",m<E)});for(let y=1;y<=4;y++){const m=document.getElementById(`recipe-step-${y}`);m&&(m.style.display=y===E?"":"none")}P.style.display=E>1&&E<4?"":"none",E===4?(g.style.display="none",z.textContent="Close"):E===3?(g.textContent="\u{1F680} Deploy",g.style.display="",z.textContent="Cancel"):(g.textContent="Next",g.style.display="",z.textContent="Cancel")}function I(){const y=document.getElementById("recipe-component-list");y.innerHTML="";const m=O.components||[];for(const b of m){const p=document.createElement("div");p.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=b.required,s=b.internal;p.innerHTML=`
|
||||
<input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${b.id}"
|
||||
<div class="app-option-icon">${escapeHtml(H.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(H.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(H.description||"")}</div>
|
||||
${c}
|
||||
`,r.onclick=()=>{if(!M){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(H)},u.appendChild(r)}};function $(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(u){A=u,E=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,T(),S(),P.classList.add("show")}function T(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const m=parseInt(u.dataset.step);u.classList.toggle("active",m===E),u.classList.toggle("completed",m<E)});for(let u=1;u<=4;u++){const m=document.getElementById(`recipe-step-${u}`);m&&(m.style.display=u===E?"":"none")}D.style.display=E>1&&E<4?"":"none",E===4?(v.style.display="none",z.textContent="Close"):E===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const m=A.components||[];for(const h of m){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const H=h.required,r=h.internal;g.innerHTML=`
|
||||
<input type="checkbox" ${H?"checked disabled":"checked"} data-component-id="${h.id}"
|
||||
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(b.role||b.id)}</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(h.role||h.id)}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">
|
||||
${b.templateRef?escapeHtml(b.templateRef):"Built-in"}
|
||||
${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
${h.templateRef?escapeHtml(h.templateRef):"Built-in"}
|
||||
${H?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
</div>
|
||||
${b.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(b.note)}</div>`:""}
|
||||
${h.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(h.note)}</div>`:""}
|
||||
</div>
|
||||
`,y.appendChild(p)}}function C(){const y=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),b=O.sharedVolumes;if(b&&Object.keys(b).length>0){y.style.display="",m.innerHTML="";for(const[p,u]of Object.entries(b)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(u.label||p)}</label>
|
||||
<input type="text" data-volume-key="${p}" value="${escapeHtml(u.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(u.defaultPath||"/path")}"
|
||||
`,u.appendChild(g)}}function L(){const u=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),h=A.sharedVolumes;if(h&&Object.keys(h).length>0){u.style.display="",m.innerHTML="";for(const[g,H]of Object.entries(h)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(H.label||g)}</label>
|
||||
<input type="text" data-volume-key="${g}" value="${escapeHtml(H.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(H.defaultPath||"/path")}"
|
||||
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
|
||||
<div class="form-hint-sm">${escapeHtml(u.description||"")}</div>
|
||||
`,m.appendChild(s)}}else y.style.display="none"}function j(){const y=document.getElementById("recipe-review-content"),m=M(),b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};b.forEach(r=>{p[r.dataset.volumeKey]=r.value});const u=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",d=document.getElementById("recipe-tailscale").checked;y.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(O.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(O.description||"")}</div>
|
||||
<div class="form-hint-sm">${escapeHtml(H.description||"")}</div>
|
||||
`,m.appendChild(r)}}else u.style.display="none"}function j(){const u=document.getElementById("recipe-review-content"),m=C(),h=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};h.forEach(d=>{g[d.dataset.volumeKey]=d.value});const H=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong>Components (${m.length}):</strong>
|
||||
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
||||
${m.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
||||
${m.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(d.role||d.id)}</strong> ${d.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${Object.keys(p).length>0?`<div style="margin-bottom: 12px;">
|
||||
${Object.keys(g).length>0?`<div style="margin-bottom: 12px;">
|
||||
<strong>Volumes:</strong>
|
||||
${Object.entries(p).map(([r,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(f)}</div>`).join("")}
|
||||
${Object.entries(g).map(([d,b])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(b)}</div>`).join("")}
|
||||
</div>`:""}
|
||||
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
Timezone: ${escapeHtml(u)} • IP: ${escapeHtml(s)} ${d?"• Tailscale only":""}
|
||||
Timezone: ${escapeHtml(H)} • IP: ${escapeHtml(r)} ${c?"• Tailscale only":""}
|
||||
</div>
|
||||
|
||||
${O.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(O.network.name)}</code></div>`:""}
|
||||
`}function M(){const y=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;y.forEach(p=>{p.checked&&m.add(p.dataset.componentId)});const b=O.components||[];return b.filter(p=>p.required).forEach(p=>m.add(p.id)),b.filter(p=>m.has(p.id))}async function D(){const y=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",y.innerHTML="";const b=M();for(const d of b){const r=document.createElement("div");r.id=`recipe-progress-${d.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
|
||||
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""}
|
||||
`}function C(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;u.forEach(g=>{g.checked&&m.add(g.dataset.componentId)});const h=A.components||[];return h.filter(g=>g.required).forEach(g=>m.add(g.id)),h.filter(g=>m.has(g.id))}async function N(){const u=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",u.innerHTML="";const h=C();for(const c of h){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
|
||||
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
||||
<span style="flex:1; font-weight: 500;">${escapeHtml(d.role||d.id)}</span>
|
||||
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
||||
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
||||
`,y.appendChild(r)}const p=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};p.forEach(d=>{u[d.dataset.volumeKey]=d.value});const s={selectedComponents:b.map(d=>d.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:u},componentOverrides:{}};for(const d of b)x(d.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:O.id,config:s})})).json();if(r.success){for(const f of r.deployed||[])x(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of r.errors||[])x(f.componentId,"error",f.error);m.style.display="",m.innerHTML=`
|
||||
`,u.appendChild(d)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),H={};g.forEach(c=>{H[c.dataset.volumeKey]=c.value});const r={selectedComponents:h.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:H},componentOverrides:{}};for(const c of h)R(c.id,"deploying","Deploying...");try{const d=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:r})})).json();if(d.success){for(const b of d.deployed||[])R(b.id,"success",b.url?`Running \u2192 ${b.url}`:"Running");for(const b of d.errors||[])R(b.componentId,"error",b.error);m.style.display="",m.innerHTML=`
|
||||
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(r.message||"Deployed!")}</div>
|
||||
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(d.message||"Deployed!")}</div>
|
||||
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<strong>Setup tips:</strong>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(b=>`<li>${escapeHtml(b)}</li>`).join("")}</ul>
|
||||
</div>`:""}
|
||||
</div>
|
||||
`,showNotification(`${O.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(d){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(d.message)}
|
||||
</div>`}}function x(y,m,b){const p=document.getElementById(`recipe-progress-${y}`);if(!p)return;const u=p.querySelector(".recipe-progress-icon"),s=p.querySelector(".recipe-progress-status");m==="deploying"?(u.textContent="\u23F3",s.style.color="var(--accent)"):m==="success"?(u.textContent="\u2705",s.style.color="var(--ok-fg)"):m==="error"&&(u.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=b}g.addEventListener("click",()=>{if(E===3){E=4,B(),D();return}E<3&&(E++,B(),E===2&&C(),E===3&&j())}),P.addEventListener("click",()=>{E>1&&E<4&&(E--,B())}),window.groupRecipeCards=function(){const y=document.querySelectorAll(".service-card[data-recipe-id]");if(y.length===0)return;const m={};y.forEach(b=>{const p=b.dataset.recipeId;m[p]||(m[p]=[]),m[p].push(b)});for(const[b,p]of Object.entries(m))p.length<2||p.forEach((u,s)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let d=u.querySelector(".recipe-group-label");d||(d=document.createElement("div"),d.className="recipe-group-label",d.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",d.textContent=b.replace(/-/g," "),u.style.position="relative",u.appendChild(d))}})},window.manageRecipe=async function(y,m){const b=`/api/v1/recipes/${y}/${m}`,p=m==="remove"?"DELETE":"POST",u=m==="remove"?`/api/v1/recipes/${y}`:b;if(!(m==="remove"&&!confirm(`Remove the entire ${y} recipe? This will delete all containers and configuration.`)))try{const d=await(await secureFetch(u,{method:p})).json();d.success?(showNotification(`Recipe ${m}: ${d.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${d.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const A=document.createElement("style");A.textContent=`
|
||||
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(d.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
||||
</div>`}}function R(u,m,h){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const H=g.querySelector(".recipe-progress-icon"),r=g.querySelector(".recipe-progress-status");m==="deploying"?(H.textContent="\u23F3",r.style.color="var(--accent)"):m==="success"?(H.textContent="\u2705",r.style.color="var(--ok-fg)"):m==="error"&&(H.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=h}v.addEventListener("click",()=>{if(E===3){E=4,T(),N();return}E<3&&(E++,T(),E===2&&L(),E===3&&j())}),D.addEventListener("click",()=>{E>1&&E<4&&(E--,T())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const m={};u.forEach(h=>{const g=h.dataset.recipeId;m[g]||(m[g]=[]),m[g].push(h)});for(const[h,g]of Object.entries(m))g.length<2||g.forEach((H,r)=>{if(H.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=H.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=h.replace(/-/g," "),H.style.position="relative",H.appendChild(c))}})},window.manageRecipe=async function(u,m){const h=`/api/v1/recipes/${u}/${m}`,g=m==="remove"?"DELETE":"POST",H=m==="remove"?`/api/v1/recipes/${u}`:h;if(!(m==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(H,{method:g})).json();c.success?(showNotification(`Recipe ${m}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const U=document.createElement("style");U.textContent=`
|
||||
.recipe-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
.recipe-step-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
`,document.head.appendChild(A),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),S=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const O=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await O.json();if(O.ok&&E.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(O){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${O.message}`,"error"),setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),S=document.getElementById("error-log-content"),O=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function z(){S.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?S.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':S.innerHTML=L.logs.map(w=>`
|
||||
`,document.head.appendChild(U),x()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const f=document.getElementById("reload-caddy-top"),I=f.textContent;try{f.textContent="\u23F3 Reloading...",f.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await A.json();if(A.ok&&E.success)f.textContent="\u2705 Reloaded!",setTimeout(()=>{f.textContent=I,f.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(A){f.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{f.textContent=I,f.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const f=document.getElementById("error-log-modal"),I=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),M=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function z(){I.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const B=await(await fetch("/api/v1/error-logs")).json();B.success&&B.logs?B.logs.length===0?I.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':I.innerHTML=B.logs.map(x=>`
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${new Date(w.timestamp).toLocaleString()}</span>
|
||||
<span class="log-timestamp">${new Date(x.timestamp).toLocaleString()}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(w.context)}</strong>: ${escapeHtml(w.error)}
|
||||
${w.details?`<br><small style="opacity: 0.7;">${escapeHtml(w.details)}</small>`:""}
|
||||
<strong>${escapeHtml(x.context)}</strong>: ${escapeHtml(x.error)}
|
||||
${x.details?`<br><small style="opacity: 0.7;">${escapeHtml(x.details)}</small>`:""}
|
||||
</div>
|
||||
</div>
|
||||
`).join(""):S.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){S.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}O?.addEventListener("click",()=>{h.classList.add("show"),z()}),E?.addEventListener("click",z),N?.addEventListener("click",P),wireModal(h,R)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
`).join(""):I.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){I.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function D(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}A?.addEventListener("click",()=>{f.classList.add("show"),z()}),E?.addEventListener("click",z),M?.addEventListener("click",D),wireModal(f,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
|
||||
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
|
||||
<div>
|
||||
@@ -609,14 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("container-logs-modal"),S=document.getElementById("cl-container-select"),O=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),T=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),I=document.getElementById("cl-line-count"),C=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),M=document.getElementById("cl-status"),D=document.getElementById("cl-created");let x=null,A=[],y=[],m=null,b=!1,p=null;function u(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function s(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function d(i,c){const $=i.stream==="stderr"?"log-stderr":"log-stdout",H=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${$}" data-index="${c}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${c+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${i.stream==="stderr"?"#f59e0b":"#22c55e"};">${H}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(i.text)}</span>
|
||||
</div>`);const f=document.getElementById("container-logs-modal"),I=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),M=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),B=document.getElementById("cl-close"),x=document.getElementById("cl-close-btn"),$=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),T=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),L=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),C=document.getElementById("cl-status"),N=document.getElementById("cl-created");let R=null,U=[],u=[],m=null,h=!1,g=null;function H(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function r(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const p=s.stream==="stderr"?"log-stderr":"log-stdout",w=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${p}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${w}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(s.text)}</span>
|
||||
</div>
|
||||
`}function r(i,c=""){if(!i||i.length===0){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',I.textContent="0 lines",C.textContent="0 filtered";return}if(A=i,y=c?i.filter($=>$.text&&$.text.toLowerCase().includes(c.toLowerCase())):i,I.textContent=`${i.length} lines`,C.textContent=c?`${y.length} of ${i.length} shown`:`${i.length} shown`,y.length===0){O.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(c)}"</div>`;return}O.innerHTML=y.map(($,H)=>d($,H)).join(""),O.scrollTop=O.scrollHeight}async function f(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],$=S.value;S.innerHTML='<option value="">Select a container...</option>',c.forEach(H=>{const U=document.createElement("option");U.value=H.id,U.textContent=`${H.name} (${H.image.split(":")[0]}) - ${H.status}`,U.dataset.name=H.name,U.dataset.image=H.image,U.dataset.status=H.status,U.dataset.created=H.created,S.appendChild(U)}),$&&S.querySelector(`option[value="${$}"]`)&&(S.value=$,v($))}catch(i){console.error("Failed to load containers:",i)}}function v(i){const c=S.querySelector(`option[value="${i}"]`);c&&(j.textContent=c.dataset.image||"-",M.textContent=c.dataset.status||"-",M.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",D.textContent=u(c.dataset.created))}async function t(){const i=S.value;if(!i){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),x=i,v(i);const c=N.value,$=E.value.trim();O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const H=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,U=await getJSON(H);U.logs&&U.logs.length>0?r(U.logs,$):(O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',I.textContent="0 lines",C.textContent="0 filtered")}catch(H){O.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(H.message)}</div>`}}function e(){const i=S.value;if(!i)return;n(),b=!0,z.textContent="\u23F9 Stop",T.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;m=new EventSource(c),m.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},m.onmessage=$=>{try{const H=JSON.parse($.data);if(H.error){k.textContent="\u{1F534}",B.textContent=`Error: ${H.error}`;return}A.push(H),y.push(H),I.textContent=`${A.length} lines`,C.textContent=`${y.length} shown`;const U=E.value.trim();if(!U||H.text&&H.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=d(H,y.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",O.appendChild(F),O.scrollTop=O.scrollHeight}}catch(H){console.error("Error parsing log:",H)}},m.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",b=!1,z.textContent="\u25B6 Stream"},h._eventSource=m}function n(){m&&(m.close(),m=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),b=!1,z.textContent="\u25B6 Stream",T.style.display="none"}function a(){if(!A||A.length===0){showNotification("No logs to download","error");return}const i=S.querySelector(`option[value="${x}"]`)?.dataset.name||x,c=new Date().toISOString().replace(/[:.]/g,"-"),$=`${i}-logs-${c}.txt`,H=A.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
|
||||
`),U=new Blob([H],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=$,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${A.length} log lines`,"success")}S?.addEventListener("change",()=>{t()}),N?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{b?n():e()}),P?.addEventListener("click",()=>{a()}),g?.addEventListener("click",()=>{E.value="",r(A,"")}),E?.addEventListener("input",()=>{clearTimeout(p),p=setTimeout(()=>{r(A,E.value.trim())},300)}),E?.addEventListener("keydown",i=>{i.key==="Escape"&&(E.value="",r(A,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),f()});function o(){n(),h.classList.remove("show")}L?.addEventListener("click",o),w?.addEventListener("click",o),document.addEventListener("keydown",i=>{i.key==="Escape"&&h.classList.contains("show")&&o()}),h.addEventListener("click",i=>{i.target===h&&o()}),window.openContainerLogsModal=function(i,c){h.classList.add("show"),f().then(()=>{const $=Array.from(S.options).find(H=>H.value===i||H.dataset.name===c);$?(S.value=$.value,v($.value),t()):i?(x=i,j.textContent=c||i,M.textContent="-",D.textContent="-",t()):O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
`}function d(s,l=""){if(!s||s.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",L.textContent="0 filtered";return}if(U=s,u=l?s.filter(p=>p.text&&p.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,L.textContent=l?`${u.length} of ${s.length} shown`:`${s.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}A.innerHTML=u.map((p,w)=>c(p,w)).join(""),A.scrollTop=A.scrollHeight}async function b(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],p=I.value;I.innerHTML='<option value="">Select a container...</option>',l.forEach(w=>{const O=document.createElement("option");O.value=w.id,O.textContent=`${w.name} (${w.image.split(":")[0]}) - ${w.status}`,O.dataset.name=w.name,O.dataset.image=w.image,O.dataset.status=w.status,O.dataset.created=w.created,I.appendChild(O)}),p&&I.querySelector(`option[value="${p}"]`)&&(I.value=p,y(p))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=I.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",C.textContent=l.dataset.status||"-",C.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",N.textContent=H(l.dataset.created))}async function t(){const s=I.value;if(!s){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),R=s,y(s);const l=M.value,p=E.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const w=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,O=await getJSON(w);O.logs&&O.logs.length>0?d(O.logs,p):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",L.textContent="0 filtered")}catch(w){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(w.message)}</div>`}}function e(){const s=I.value;if(!s)return;a(),h=!0,z.textContent="\u23F9 Stop",$.style.display="flex",k.textContent="\u{1F7E2}",T.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;m=new EventSource(l),m.onopen=()=>{k.textContent="\u{1F7E2}",T.textContent="Connected - streaming logs"},m.onmessage=p=>{try{const w=JSON.parse(p.data);if(w.error){k.textContent="\u{1F534}",T.textContent=`Error: ${w.error}`;return}U.push(w),u.push(w),S.textContent=`${U.length} lines`,L.textContent=`${u.length} shown`;const O=E.value.trim();if(!O||w.text&&w.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(w,u.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(w){console.error("Error parsing log:",w)}},m.onerror=()=>{k.textContent="\u{1F534}",T.textContent="Disconnected",h=!1,z.textContent="\u25B6 Stream"},f._eventSource=m}function a(){m&&(m.close(),m=null),f._eventSource&&(f._eventSource.close(),f._eventSource=null),h=!1,z.textContent="\u25B6 Stream",$.style.display="none"}function o(){if(!U||U.length===0){showNotification("No logs to download","error");return}const s=I.querySelector(`option[value="${R}"]`)?.dataset.name||R,l=new Date().toISOString().replace(/[:.]/g,"-"),p=`${s}-logs-${l}.txt`,w=U.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
|
||||
`),O=new Blob([w],{type:"text/plain"}),_=URL.createObjectURL(O),F=document.createElement("a");F.href=_,F.download=p,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${U.length} log lines`,"success")}I?.addEventListener("change",()=>{t()}),M?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{h?a():e()}),D?.addEventListener("click",()=>{o()}),v?.addEventListener("click",()=>{E.value="",d(U,"")}),E?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{d(U,E.value.trim())},300)}),E?.addEventListener("keydown",s=>{s.key==="Escape"&&(E.value="",d(U,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{f.classList.add("show"),b()});function n(){a(),f.classList.remove("show")}B?.addEventListener("click",n),x?.addEventListener("click",n),document.addEventListener("keydown",s=>{s.key==="Escape"&&f.classList.contains("show")&&n()}),f.addEventListener("click",s=>{s.target===f&&n()}),window.openContainerLogsModal=function(s,l){f.classList.add("show"),b().then(()=>{const p=Array.from(I.options).find(w=>w.value===s||w.dataset.name===l);p?(I.value=p.value,y(p.value),t()):s?(R=s,j.textContent=l||s,C.textContent="-",N.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
||||
<h3>\u{1F4BE} Container Snapshots</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -670,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="snapshot-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("snapshot-modal"),S=document.getElementById("snapshot-btn"),O=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let P=null;async function g(){try{const I=await(await fetch("/api/v1/containers")).json();if(!I.success||!I.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const C of I.containers){const j=document.createElement("option");j.value=C.id,j.textContent=`${C.name||C.id} (${C.image||"unknown"})`,j.dataset.name=C.name,j.dataset.image=C.image,j.dataset.status=C.status,j.dataset.created=C.created,E.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",P=null;return}P=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function w(){if(!P){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const I=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:I})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(C){z.textContent=`\u2717 Error: ${C.message}`,z.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function T(){h.classList.add("show"),g()}function k(){h.classList.remove("show"),N.style.display="none",P=null,E.selectedIndex=0}S?.addEventListener("click",T),O?.addEventListener("click",k),wireModal(h,O),E?.addEventListener("change",B=>{const I=E.options[E.selectedIndex];L(I)}),R?.addEventListener("click",w),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(I=>I.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(I=>I.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
</div>`);const f=document.getElementById("snapshot-modal"),I=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),M=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let D=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const L of S.containers){const j=document.createElement("option");j.value=L.id,j.textContent=`${L.name||L.id} (${L.image||"unknown"})`,j.dataset.name=L.name,j.dataset.image=L.image,j.dataset.status=L.status,j.dataset.created=L.created,E.appendChild(j)}}catch(T){console.error("Failed to load containers:",T)}}function B(T){if(!T||!T.value){M.style.display="none",D=null;return}D=T.value,document.getElementById("snapshot-image").textContent=T.dataset.image||"-",document.getElementById("snapshot-status").textContent=T.dataset.status||"-",document.getElementById("snapshot-created").textContent=T.dataset.created?new Date(T.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=T.value.substring(0,12),M.style.display=""}async function x(){if(!D){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const T=document.getElementById("snapshot-name").value.trim();if(!T){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${T}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(L){z.textContent=`\u2717 Error: ${L.message}`,z.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function $(){f.classList.add("show"),v()}function k(){f.classList.remove("show"),M.style.display="none",D=null,E.selectedIndex=0}I?.addEventListener("click",$),A?.addEventListener("click",k),wireModal(f,A),E?.addEventListener("change",T=>{const S=E.options[E.selectedIndex];B(S)}),P?.addEventListener("click",x),f?.querySelectorAll(".panel-tab").forEach(T=>{T.addEventListener("click",()=>{f.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),f.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),T.classList.add("active"),f.querySelector(`#${T.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
||||
<h3>\u{1F3AC} Smart Arr Connect</h3>
|
||||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||||
@@ -749,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="arr-setup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("arr-setup-modal"),S=document.getElementById("arr-setup-btn"),O=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),T=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=document.getElementById("smart-retry-btn");let I=null;const C={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(p){N.style.display=p==="detect"?"block":"none",R.style.display=p==="credentials"?"block":"none",z.style.display=p==="progress"?"block":"none",P.style.display=p==="results"?"block":"none"}function D(p){const u={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},s=u[p]||u.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function x(){M("detect"),g.style.display="none";try{if(I=await(await fetch("/api/v1/arr/smart-detect")).json(),!I.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(I.error)}</div>`,g.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[d,r]of Object.entries(I.services)){const f=C[d]||"\u{1F4E6}",v=j[d]||d,t=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",e=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",n=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${f}</span>
|
||||
</div>`);const f=document.getElementById("arr-setup-modal"),I=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),M=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),B=document.getElementById("smart-credential-inputs"),x=document.getElementById("smart-progress-steps"),$=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),T=document.getElementById("smart-retry-btn");let S=null;const L={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(g){M.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",z.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function N(g){const H={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},r=H[g]||H.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function R(){C("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let H='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(S.services)){const b=L[c]||"\u{1F4E6}",y=j[c]||c,t=d.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(d.source)}</span>`:"",e=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",a=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";H+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${b}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${v}</div>
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
${t} ${e} ${n}
|
||||
${t} ${e} ${a}
|
||||
</div>
|
||||
</div>
|
||||
${D(r.status)}
|
||||
</div>`}u+="</div>";const s=I.summary;u+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` · <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,g.innerHTML=u,g.style.display="block",A(I),setTimeout(()=>{M("credentials")},800)}catch(p){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(p.message)}</div>`,g.style.display="block"}}function A(p){let u="";const s=p.services,d=["radarr","sonarr","prowlarr"];for(const v of d){const t=s[v];if(!t||t.status==="not_found"&&!t.url)continue;const e=C[v],n=j[v],a=t.status==="connected";u+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};">
|
||||
${N(d.status)}
|
||||
</div>`}H+="</div>";const r=S.summary;H+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` · <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,v.innerHTML=H,v.style.display="block",U(S),setTimeout(()=>{C("credentials")},800)}catch(g){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,v.style.display="block"}}function U(g){let H="";const r=g.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const t=r[y];if(!t||t.status==="not_found"&&!t.url)continue;const e=L[y],a=j[y],o=t.status==="connected";H+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="font-size: 1.1rem;">${e}</span>
|
||||
<span style="font-weight: 500;">${n}</span>
|
||||
<span id="smart-${v}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${a?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
||||
<span style="font-weight: 500;">${a}</span>
|
||||
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${o?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
||||
<input type="text" id="smart-${v}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${v}/"
|
||||
<input type="text" id="smart-${y}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${y}/"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
||||
<input type="password" id="smart-${v}-key" placeholder="${a?"(saved)":"Paste API key"}"
|
||||
<input type="password" id="smart-${y}-key" placeholder="${o?"(saved)":"Paste API key"}"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="smartTestConnection('${v}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const r=s.plex;if(r){const v=r.status==="connected";u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
||||
<button onclick="smartTestConnection('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const d=r.plex;if(d){const y=d.status==="connected";H+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
|
||||
<span style="font-weight: 500;">Plex</span>
|
||||
${D(r.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
|
||||
${N(d.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
|
||||
</div>
|
||||
</div>`}const f=s.seerr;if(f){const v=f.status==="connected";let t="";if(f.configuredServices){const e=f.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
</div>`}const b=r.seerr;if(b){const y=b.status==="connected";let t="";if(b.configuredServices){const e=b.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
||||
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
||||
${e.plex?"✓ Plex":"✗ Plex"}
|
||||
</div>`}u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"};">
|
||||
</div>`}H+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
||||
<span style="font-weight: 500;">Seerr</span>
|
||||
${D(f.status)}
|
||||
${N(b.status)}
|
||||
</div>
|
||||
${t}
|
||||
</div>`}L.innerHTML=u}window.smartTestConnection=async function(p){const u=document.getElementById(`smart-${p}-url`),s=document.getElementById(`smart-${p}-key`),d=document.getElementById(`smart-${p}-status`),r=u?.value.trim(),f=s?.value.trim();if(!r||!f){d.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}d.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:p,url:r,apiKey:f})})).json();t.success?d.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:d.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(t.error)}</span>`}catch(v){d.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(v.message)}</span>`}};async function y(){M("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const p={};for(const s of["radarr","sonarr","prowlarr"]){const d=document.getElementById(`smart-${s}-url`)?.value.trim(),r=document.getElementById(`smart-${s}-key`)?.value.trim();r&&d?p[s]={apiKey:r,url:d}:r&&(p[s]={apiKey:r})}const u={services:Object.keys(p).length>0?p:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const d=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)})).json();let r="";for(const f of d.steps||[]){const v=f.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',t=f.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${v}
|
||||
<span>${escapeHtml(f.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(f.details||"")}</span>
|
||||
</div>`}w.innerHTML=r,setTimeout(()=>m(d),500)}catch(s){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function m(p){M("results");const u=p.summary||{},s=u.failed===0&&u.succeeded>0,d=s?"var(--ok-fg)":"#f39c12",r=s?"✓":"⚠",f=s?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.totalSteps))} Steps Succeeded`;let v=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${d} 12%, transparent); border-radius: 10px; border: 1px solid ${d}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${d};">${r}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${d};">${f}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
|
||||
</div>`;v+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of p.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';v+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
</div>`}B.innerHTML=H}window.smartTestConnection=async function(g){const H=document.getElementById(`smart-${g}-url`),r=document.getElementById(`smart-${g}-key`),c=document.getElementById(`smart-${g}-status`),d=H?.value.trim(),b=r?.value.trim();if(!d||!b){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:d,apiKey:b})})).json();t.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(t.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(y.message)}</span>`}};async function u(){C("progress"),x.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),d=document.getElementById(`smart-${r}-key`)?.value.trim();d&&c?g[r]={apiKey:d,url:c}:d&&(g[r]={apiKey:d})}const H={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(H)})).json();let d="";for(const b of c.steps||[]){const y=b.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',t=b.status==="success"?"var(--muted)":"var(--bad-fg)";d+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${y}
|
||||
<span>${escapeHtml(b.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(b.details||"")}</span>
|
||||
</div>`}x.innerHTML=d,setTimeout(()=>m(c),500)}catch(r){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function m(g){C("results");const H=g.summary||{},r=H.failed===0&&H.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",d=r?"✓":"⚠",b=r?"All Connected!":`${escapeHtml(String(H.succeeded))}/${escapeHtml(String(H.totalSteps))} Steps Succeeded`;let y=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${c};">${d}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${b}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(H.succeeded))} succeeded, ${escapeHtml(String(H.failed))} failed</div>
|
||||
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
|
||||
</div>`}v+="</div>",T.innerHTML=v,B.style.display=u.failed>0?"block":"none",p.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&b()}async function b(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(u.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const d of u.libraries){const r=d.type==="movie"?"\u{1F3AC}":d.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${r} <strong>${escapeHtml(d.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(d.count))} items</span>
|
||||
</div>`}s+="</div></div>",k.innerHTML=s,k.style.display="block"}}catch{}}S?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,O),E?.addEventListener("click",y),B?.addEventListener("click",y)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
</div>`}y+="</div>",$.innerHTML=y,T.style.display=H.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&h()}async function h(){try{const H=await(await fetch("/api/v1/plex/libraries")).json();if(H.success&&H.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(H.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of H.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${d} <strong>${escapeHtml(c.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
||||
</div>`}r+="</div></div>",k.innerHTML=r,k.style.display="block"}}catch{}}I?.addEventListener("click",()=>{f.classList.add("show"),k.style.display="none",R()}),wireModal(f,A),E?.addEventListener("click",u),T?.addEventListener("click",u)})(),(function(){const f=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F514} Notification Settings</h3>
|
||||
|
||||
@@ -1000,15 +1000,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const S=document.getElementById("notifications-modal"),O=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),I=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{I.style.display=B.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");R?.addEventListener("change",()=>{z.style.opacity=R.checked?"1":"0.5"});async function P(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const I=B.config;document.getElementById("notifications-enabled").checked=I.enabled,document.getElementById("discord-enabled").checked=I.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=I.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=I.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=I.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=I.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=I.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=I.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=I.providers?.email?.enabled?"block":"none",I.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=I.providers.ntfy.serverUrl),I.providers?.email?.host&&(document.getElementById("email-host").value=I.providers.email.host),I.providers?.email?.from&&(document.getElementById("email-from").value=I.providers.email.from),document.getElementById("health-check-enabled").checked=I.healthCheck?.enabled||!1,I.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=I.healthCheck.intervalMinutes),I.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(I.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=I.events?.containerDown!==!1,document.getElementById("event-container-up").checked=I.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=I.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=I.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=I.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function g(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),I=document.getElementById("notification-history");B.success&&B.history?.length>0?I.innerHTML=B.history.map(C=>{const j=new Date(C.timestamp).toLocaleString();return`
|
||||
</div>`);const I=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),M=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const T=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);T?.addEventListener("change",()=>{S.style.display=T.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");P?.addEventListener("change",()=>{z.style.opacity=P.checked?"1":"0.5"});async function D(){try{const T=await(await fetch("/api/v1/notifications/config")).json();if(T.success){const S=T.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){f.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const T=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");T.success&&T.history?.length>0?S.innerHTML=T.history.map(L=>{const j=new Date(L.timestamp).toLocaleString();return`
|
||||
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[C.type]||"var(--muted)"}">${C.type==="success"?"\u2713":C.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[L.type]||"var(--muted)"}">${L.type==="success"?"\u2713":L.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(C.title)}</div>
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(L.title)}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join(""):I.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},I=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();I.success?(showNotification("Notification settings saved","success",3e3),S.classList.remove("show")):showNotification(`Failed to save: ${I.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function w(k){try{const I=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();I.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${I.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),O?.addEventListener("click",()=>{S.classList.add("show"),P(),g()}),E?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const C=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();C.success?(showNotification("Test notification sent!","success",3e3),T()):showNotification(`Test failed: ${C.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function T(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const I=document.getElementById("last-notification-sent");I&&(I.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(S,N)})(),(function(){document.addEventListener("click",h=>{const S=h.target.closest(".panel-tab");if(!S)return;const O=S.dataset.panel;if(!O)return;const E=S.closest(".panel-tabs"),N=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),S.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const R=N.querySelector("#"+O);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca
Line truncated
|
||||
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){f.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function B(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),I.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function x(k){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();S.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(T){showNotification(`Error: ${T.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>x("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>x("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>x("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>x("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const T=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();T.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(T.lastCheck).toLocaleString()} (${T.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{I.classList.add("show"),D(),v()}),E?.addEventListener("click",B),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),T=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const L=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();L.success?(showNotification("Test notification sent!","success",3e3),$()):showNotification(`Test failed: ${L.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=T,k.disabled=!1}});async function $(){try{const T=await(await fetch("/api/v1/notifications/status")).json();if(T.success&&T.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(T.lastSent).toLocaleString()}`)}}catch{}}wireModal(I,M)})(),(function(){document.addEventListener("click",f=>{const I=f.target.closest(".panel-tab");if(!I)return;const A=I.dataset.panel;if(!A)return;const E=I.closest(".panel-tabs"),M=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),I.classList.add("active"),M.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const P=M.querySelector("#"+A);P&&P.classList.add("active")})})(),(function(){var f=["dashcaddy_site_config","dashca
Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F4BE} Backup & Restore</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1114,9 +1114,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="backup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);var R=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),T=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),I=document.getElementById("backup-do-restore-btn"),C=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),M=document.getElementById("backup-history-container"),D=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),A=null;z?.addEventListener("click",function(){R.classList.add("show"),C&&(C.style.display="none"),k&&(k.style.display="none"),T&&(T.style.display="none"),A=null}),wireModal(R,P),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=S();var a=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),l=URL.createObjectURL(a),o=document.createElement("a");o.href=l,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(l);var i=Object.keys(n.browserState).length,c=n.themes?Object.keys(n.themes).length:0;C.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),C.style.display="block",C.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",C.style.border="1px solid var(--ok-fg)"}catch($){C.innerHTML="\u274C Export failed: "+escapeHtml($.message),C.style.display="block",C.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",C.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){T.textContent="\u{1F4C4} "+n.name,T.style.display="block",C.style.display="none";try{var a=await n.text(),l=JSON.parse(a);if(E(l)){A=l;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(l.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',l.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+l.services.length+" services</span>"),l.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+l.customApps.length+" custom apps</span>"),l.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(l.theme)+"</span>"),l.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(l.userThemes).length+" custom themes</span>"),o+="</div>",B.innerHTML=o,k.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}),c=await i.json();if(c.success){A=l;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(l.exportedAt).toLocaleString()+" (v"+escapeHtml(l.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var $ in c.preview.files){var H=c.preview.files[$],U=H.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(H.description)+"</span>"}o+="</div>",c.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),B.innerHTML=o,k.style.display="block"}else C.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),C.style.display="block",C.style.background="color-mix(in srgb, #f39c12 15%, transparent)",C.style.border="1px solid #f39c12",k.style.display="none"}catch(q){C.innerHTML="\u274CLine truncated
|
||||
</div>`);var P=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),B=document.getElementById("backup-select-file"),x=document.getElementById("backup-file-input"),$=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),T=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),L=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),C=document.getElementById("backup-history-container"),N=document.getElementById("backup-disk-container"),R=document.getElementById("pointintime-container"),U=null;z?.addEventListener("click",function(){P.classList.add("show"),L&&(L.style.display="none"),k&&(k.style.display="none"),$&&($.style.display="none"),U=null}),wireModal(P,D),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=I();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var s=Object.keys(a.browserState).length,l=a.themes?Object.keys(a.themes).length:0;L.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),L.style.display="block",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)"}catch(p){L.innerHTML="\u274C Export failed: "+escapeHtml(p.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),B?.addEventListener("click",function(){x.click()}),x?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){$.textContent="\u{1F4C4} "+a.name,$.style.display="block",L.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(E(i)){U=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",T.innerHTML=n,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await s.json();if(l.success){U=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var p in l.preview.files){var w=l.preview.files[p],O=w.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+O+" "+escapeHtml(w.description)+"</span>"}n+="</div>",l.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),T.innerHTML=n,k.style.display="block"}else L.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),L.style.display="block",L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12",k.style.display="none"}catch(_){L.innerHTML="\u274CLine truncated
|
||||
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(n),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),l=await a.json();l.success?(showNotification(e+" restored to "+n,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(l.error||"Unknown"),"error")}catch(o){showNotification("Restore error: "+o.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F4CA} Resource Monitor</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1196,11 +1196,11 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="stats-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("stats-modal"),S=document.getElementById("container-stats-btn"),O=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let L=null,w=null;function T(r){if(r===0||!r)return"0 B";const f=1024,v=["B","KB","MB","GB"],t=Math.floor(Math.log(r)/Math.log(f));return parseFloat((r/Math.pow(f,t)).toFixed(1))+" "+v[t]}function k(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function B(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function I(){try{let r=null,f=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,f=!0,w=e.stats)}catch{}if(!f){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const n of e.stats)r[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};w=r}}if(!r||Object.keys(r).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let v='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(r)){const n=e.current||e,a=n.cpu?.percent||0,l=n.memory?.percent||0,o=k(a),i=B(l),c=n.memory?.usage||n.memory?.used||0,$=n.memory?.limit||0,H=n.network?.rxBytes||n.network?.rx||0,U=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;v+=`
|
||||
</div>`);const f=document.getElementById("stats-modal"),I=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),M=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let B=null,x=null;function $(d){if(d===0||!d)return"0 B";const b=1024,y=["B","KB","MB","GB"],t=Math.floor(Math.log(d)/Math.log(b));return parseFloat((d/Math.pow(b,t)).toFixed(1))+" "+y[t]}function k(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function T(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function S(){try{let d=null,b=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(d=e.stats,b=!0,x=e.stats)}catch{}if(!b){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){d={};for(const a of e.stats)d[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};x=d}}if(!d||Object.keys(d).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(d)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=k(o),s=T(i),l=a.memory?.usage||a.memory?.used||0,p=a.memory?.limit||0,w=a.network?.rxBytes||a.network?.rx||0,O=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;y+=`
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
|
||||
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
||||
${_?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${_.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
||||
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
||||
@@ -1208,32 +1208,32 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
<div style="height: 100%; width: ${Math.min(o,100)}%; background: ${n}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${o.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(l,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
<div style="height: 100%; width: ${Math.min(i,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${l.toFixed(1)}%</span>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${T(c)} / ${T($)}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${$(l)} / ${$(p)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
||||
<div style="font-size: 0.8rem;">
|
||||
<span style="color: #3498db;">\u2193 ${T(H)}</span>
|
||||
<span style="color: #3498db;">\u2193 ${$(w)}</span>
|
||||
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
||||
<span style="color: #e74c3c;">\u2191 ${T(U)}</span>
|
||||
<span style="color: #e74c3c;">\u2191 ${$(O)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}v+="</div>",R.innerHTML=v,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function C(){if(!z)return;const r=w;if(!r||Object.keys(r).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[v,t]of Object.entries(r)){const e=t.aggregated;e&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||v}</div>
|
||||
</div>`}y+="</div>",P.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function L(){if(!z)return;const d=x;if(!d||Object.keys(d).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let b='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,t]of Object.entries(d)){const e=t.aggregated;e&&(b+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||y}</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
||||
@@ -1241,27 +1241,27 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
||||
</div>
|
||||
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
||||
</div>`)}f+="</div>",z.innerHTML=f}async function j(){if(!P)return;P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=w;if(!r||Object.keys(r).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let v=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(v=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const n=Object.entries(r).map(([o,i])=>{const c=t[o]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
||||
<tr data-container="${o}">
|
||||
<td style="font-weight: 600; padding: 8px;">${i.name||o}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${f?"":"disabled"} /></td>
|
||||
</div>`)}b+="</div>",z.innerHTML=b}async function j(){if(!D)return;D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=x;if(!d||Object.keys(d).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let b=!1;try{b=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{b=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let t={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(t=s.configs||{})}catch{}const a=Object.entries(d).map(([n,s])=>{const l=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
||||
<tr data-container="${n}">
|
||||
<td style="font-weight: 600; padding: 8px;">${s.name||n}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${b?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;">
|
||||
<button class="alert-test-btn btn-xs" data-container="${o}" data-name="${i.name||o}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${s.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join(""),a=v.map(o=>{const i=new Date(o.timestamp).toLocaleString(),c=o.notified?"\u2713":"\u2014";return`
|
||||
`}).join(""),o=y.map(n=>{const s=new Date(n.timestamp).toLocaleString(),l=n.notified?"\u2713":"\u2014";return`
|
||||
<tr>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${i}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${o.containerName||o.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${o.metric||o.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof o.value=="number"?o.value.toFixed(1):o.value}${o.metric==="disk"?" MB/s":"%"}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${c}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${o.autoRestartTriggered?"#f39c12":"var(--muted)"};">${o.autoRestartTriggered?"\u21BB":""}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${n.containerName||n.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${n.metric||n.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof n.value=="number"?n.value.toFixed(1):n.value}${n.metric==="disk"?" MB/s":"%"}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${l}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
|
||||
</tr>
|
||||
`}).join(""),l=f?`
|
||||
`}).join(""),i=b?`
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
||||
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
|
||||
@@ -1279,7 +1279,7 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<th style="padding: 6px 8px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${n}</tbody>
|
||||
<tbody>${a}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
|
||||
@@ -1292,11 +1292,11 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
|
||||
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
|
||||
</div>
|
||||
`;P.innerHTML=`
|
||||
${l}
|
||||
`;D.innerHTML=`
|
||||
${i}
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
|
||||
${a?`
|
||||
${o?`
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
|
||||
<thead>
|
||||
@@ -1309,26 +1309,26 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<th style="padding: 6px 8px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${a}</tbody>
|
||||
<tbody>${o}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
||||
</div>
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const o={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;o[c]={cpuThreshold:parseInt(i.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(i.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(i.querySelector(".alert-disk")?.value)||50,autoRestart:!!i.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const c=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:o})})).json(),$=document.getElementById("save-all-alerts");$.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{$.textContent="Save All"},2e3)}catch{const c=document.getElementById("save-all-alerts");c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",o=>{o.preventDefault(),h.classList.remove("show"),D(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.textContent;o.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${o.dataset.container}/test`,{method:"POST"}),o.textContent="\u2705",showNotification("Test alert sent for "+o.dataset.name,"success",3e3)}catch{o.textContent="\u274C"}setTimeout(()=>{o.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),D(),typeof openLicenseModal=="function"&&openLicenseModal()})}function M(){L&&clearInterval(L),N?.checked&&(L=setInterval(I,DC.POLL.STATS))}function D(){L&&(clearInterval(L),L=null)}S?.addEventListener("click",()=>{h.classList.add("show"),I(),M()}),O?.addEventListener("click",()=>{h.classList.remove("show"),D()}),h?.addEventListener("click",r=>{r.target===h&&(h.classList.remove("show"),D())}),E?.addEventListener("click",I),N?.addEventListener("change",()=>{N.checked?M():D()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",C),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),A=document.getElementById("stats-history-container-area"),y=document.querySelectorAll(".stats-range-btn");let m="1h";function b(r){switch(r){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function p(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function u(r,f,v,t,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const n=r.map(f).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=Math.max(...n,1),l=Math.min(...n,0),o=a-l||1,i=600,c=80,$=4,H=(i-$*2)/Math.max(n.length-1,1),U=n.map((G,J)=>{const X=$+J*H,Q=c-$-(G-l)/o*(c-$*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],F=n.reduce((G,J)=>G+J,0)/n.length;return`
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;n[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),p=document.getElementById("save-all-alerts");p.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{p.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),f.classList.remove("show"),N(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{f.classList.remove("show"),N(),typeof openLicenseModal=="function"&&openLicenseModal()})}function C(){B&&clearInterval(B),M?.checked&&(B=setInterval(S,DC.POLL.STATS))}function N(){B&&(clearInterval(B),B=null)}I?.addEventListener("click",()=>{f.classList.add("show"),S(),C()}),A?.addEventListener("click",()=>{f.classList.remove("show"),N()}),f?.addEventListener("click",d=>{d.target===f&&(f.classList.remove("show"),N())}),E?.addEventListener("click",S),M?.addEventListener("change",()=>{M.checked?C():N()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",L),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const R=document.getElementById("stats-history-container"),U=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let m="1h";function h(d){switch(d){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function H(d,b,y,t,e){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=d.map(b).filter(q=>q!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,s=600,l=80,p=4,w=(s-p*2)/Math.max(a.length-1,1),O=a.map((q,J)=>{const X=p+J*w,Q=l-p-(q-i)/n*(l-p*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],F=a.reduce((q,J)=>q+J,0)/a.length;return`
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
||||
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${o.toFixed(1)}${e}</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${i} ${c}" preserveAspectRatio="none" style="width: 100%; height: ${c}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${v}" stroke-width="1.5" points="${U}" />
|
||||
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${O}" />
|
||||
</svg>
|
||||
</div>
|
||||
`}function s(){if(!x)return;const r=w||{},f=x.value,v=Object.entries(r);if(v.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=v.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),f&&r[f]&&(x.value=f)}async function d(){if(!A||!x)return;const r=x.value;if(!r){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),v=f-b(m);A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${v}&endTime=${f}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],a=e.tier||"raw";if(n.length===0){A.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${p(a)}.</div>`;return}const l=a==="raw",o=l?U=>U.cpu?.percent:U=>U.cpu?.avg,i=l?U=>U.memory?.percent:U=>U.memory?.avgPercent,c=l?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,$=l?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let H=`
|
||||
`}function r(){if(!R)return;const d=x||{},b=R.value,y=Object.entries(d);if(y.length===0){R.innerHTML='<option value="">No containers</option>';return}R.innerHTML=y.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),b&&d[b]&&(R.value=b)}async function c(){if(!U||!R)return;const d=R.value;if(!d){U.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const b=Date.now(),y=b-h(m);U.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${y}&endTime=${b}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?O=>O.cpu?.percent:O=>O.cpu?.avg,s=i?O=>O.memory?.percent:O=>O.memory?.avgPercent,l=i?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,p=i?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let w=`
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
||||
${n.length} samples \xB7 ${escapeHtml(p(a))} \xB7 ${new Date(v).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
|
||||
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date(b).toLocaleString()}
|
||||
</div>
|
||||
`;H+=u(n,o,"#2ecc71","CPU","%"),H+=u(n,i,"#3498db","Memory","%"),H+=u(n,c,"#9b59b6","Network RX"," MB"),H+=u(n,$,"#e67e22","Network TX"," MB"),A.innerHTML=H}catch(t){A.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}y.forEach(r=>{r.addEventListener("click",()=>{y.forEach(f=>f.classList.remove("active")),r.classList.add("active"),m=r.dataset.range,d()})}),x?.addEventListener("change",d),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),d()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
`;w+=H(a,n,"#2ecc71","CPU","%"),w+=H(a,s,"#3498db","Memory","%"),w+=H(a,l,"#9b59b6","Network RX"," MB"),w+=H(a,p,"#e67e22","Network TX"," MB"),U.innerHTML=w}catch(t){U.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}u.forEach(d=>{d.addEventListener("click",()=>{u.forEach(b=>b.classList.remove("active")),d.classList.add("active"),m=d.dataset.range,c()})}),R?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<h3>\u{1F3E5} Health Check Dashboard</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1361,6 +1361,44 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Settings: retention, polling intervals, max entries, disk-usage threshold -->
|
||||
<div id="health-global-settings" style="margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 style="margin: 0 0 4px;">\u{1F30D} Global Settings</h4>
|
||||
<p class="text-muted-sm" style="margin: 0 0 12px;">Applies to all health checks. Settings are stored locally in this browser.</p>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Health Check Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-interval" value="60" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service's health endpoint is checked.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Stats Polling Interval (seconds)</label>
|
||||
<input type="number" id="health-setting-stats-interval" value="30" min="5" max="3600" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often container statistics (CPU/memory) are sampled.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Max Entries Per Service</label>
|
||||
<input type="number" id="health-setting-max-entries" value="500" min="10" max="100000" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Cap on stored history records per service.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Data Retention (days)</label>
|
||||
<input type="number" id="health-setting-retention" value="30" min="1" max="3650" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Health history older than this is pruned.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Disk-Usage Warning (%)</label>
|
||||
<input type="number" id="health-setting-disk-threshold" value="80" min="50" max="99" class="form-input" />
|
||||
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Warn when disk usage exceeds this level.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">
|
||||
<button id="health-global-save" class="btn-accent-solid">Save Global Settings</button>
|
||||
<button id="health-global-reset" class="btn-sm">Reset to Defaults</button>
|
||||
<span id="health-global-status" style="font-size: 0.8rem; color: var(--muted);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Health Check Form -->
|
||||
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
||||
@@ -1414,24 +1452,24 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="health-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("health-modal"),S=document.getElementById("health-check-btn"),O=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),T=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function I(y){return y>=99.9?"var(--ok-fg)":y>=95?"#f39c12":"var(--bad-fg)"}function C(y){const m={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${m[y]||"var(--muted)"}20; color: ${m[y]||"var(--muted)"};">${y}</span>`}async function j(){try{const m=await(await fetch("/api/v1/health-checks/status")).json();if(!m.success||!m.status||Object.keys(m.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const b=Object.values(m.status);let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',p+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',p+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',p+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const u of b){const s=u.status==="up",d=s?"var(--dot-ok)":"var(--dot-bad)",r=u.uptime?.["24h"]??"-",f=u.uptime?.["7d"]??"-",v=u.avgResponseTime!=null?Math.round(u.avgResponseTime)+"ms":"-",t=u.timestamp?timeAgo(u.timestamp):"-";p+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(u.serviceId)}">`,p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,p+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${d}; margin-right: 6px;"></span>${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px; color: ${typeof r=="number"?I(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,p+=`<td style="padding: 8px; color: ${typeof f=="number"?I(f):"var(--muted)"};">${typeof f=="number"?f.toFixed(1)+"%":f}</td>`,p+=`<td style="padding: 8px;">${v}</td>`,p+=`<td style="padding: 8px; color: var(--muted);">${t}</td>`,p+="</tr>",p+=`<tr id="health-detail-${escapeHtml(u.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}p+="</table>",N.innerHTML=p,P.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(u=>{u.addEventListener("click",async()=>{const s=u.dataset.healthId,d=document.getElementById("health-detail-"+s);if(d){if(d.style.display!=="none"){d.style.display="none";return}d.style.display="";try{const f=await(await fetch(`/api/v1/health-checks/${s}/stats?hours=24`)).json();if(f.success&&f.stats){const v=f.stats,t=v.responseTime||{};d.querySelector("td").innerHTML=`
|
||||
</div>`);const f=document.getElementById("health-modal"),I=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),M=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),B=document.getElementById("health-config-form"),x=document.getElementById("health-form-title"),$=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),T="dashcaddy-health-settings",S={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},L=document.getElementById("health-global-save"),j=document.getElementById("health-global-reset"),C=document.getElementById("health-global-status"),N=document.getElementById("health-setting-retention"),R=document.getElementById("health-setting-interval"),U=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),m=document.getElementById("health-setting-disk-threshold");function h(){try{const i=safeGet(T),n=i?JSON.parse(i):{};return Object.assign({},S,n)}catch{return Object.assign({},S)}}function g(){const i=h();N&&(N.value=i.retentionDays),R&&(R.value=i.pollingInterval),U&&(U.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),m&&(m.value=i.diskUsageThreshold)}function H(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(N?.value)||S.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||S.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(U?.value)||S.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||S.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(m?.value)||S.diskUsageThreshold))};try{safeSet(T,JSON.stringify(i)),g(),C&&(C.textContent="Saved \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){C&&(C.textContent="Save failed",C.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function r(){try{safeSet(T,JSON.stringify(S))}catch{}g(),C&&(C.textContent="Reset to defaults \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500))}g(),L?.addEventListener("click",H),j?.addEventListener("click",r);let c=null;function d(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function b(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function y(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const s=Object.values(n.status);let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',l+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',l+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',l+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of s){const w=p.status==="up",O=w?"var(--dot-ok)":"var(--dot-bad)",_=p.uptime?.["24h"]??"-",F=p.uptime?.["7d"]??"-",q=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",J=p.timestamp?timeAgo(p.timestamp):"-";l+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${w?"Up":"Down"}</td>`,l+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,l+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,l+=`<td style="padding: 8px;">${q}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,l+="</tr>",l+=`<tr id="health-detail-${escapeHtml(p.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${v.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${I(v.uptime||0)};">${(v.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(t.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(t.p95||0)}ms / ${Math.round(t.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(t.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(t.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${v.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${v.downChecks||0}</strong></div>
|
||||
</div>`}else d.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){d.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(y.message)}</div>`}}async function M(){try{const[y,m]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),b=await y.json(),p=await m.json();let u="";const s=b.success&&b.incidents?b.incidents:[];if(s.length>0){u+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+s.length+")</h4>";for(const r of s)u+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${d(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(J.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(J.p95||0)}ms / ${Math.round(J.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(J.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${q.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${q.downChecks||0}</strong></div>
|
||||
</div>`}else O.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){O.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),s=await i.json(),l=await n.json();let p="";const w=s.success&&s.incidents?s.incidents:[];if(w.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+w.length+")</h4>";for(const _ of w)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span>
|
||||
<span>${C(r.severity)}</span>
|
||||
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
|
||||
<span>${b(_.severity)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
|
||||
</div>`;u+="</div>"}else u+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const d=p.success&&p.history?p.history:[];if(d.length>0){u+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',u+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of d){const f=r.status==="resolved",v=f&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,u+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,u+=`<td style="padding: 6px;">${C(r.severity)}</td>`,u+=`<td style="padding: 6px;"><span style="color: ${f?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,u+=`<td style="padding: 6px;">${v}</td>`,u+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,u+="</tr>"}u+="</table>"}R.innerHTML=u||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function D(){try{const m=await(await fetch("/api/v1/health-checks/status")).json(),b=m.success&&m.status?Object.values(m.status):[];if(b.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const u of b){const s=u.status==="up";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,p+=`<td style="padding: 8px; color: ${s?"var(--ok-fg)":"var(--bad-fg)"};">${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px;">${u.sla?.target?u.sla.target+"%":"-"}</td>`,p+='<td style="padding: 8px; text-align: right;">',p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,p+="</td></tr>"}p+="</table>",z.innerHTML=p}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}function x(y,m,b,p,u,s,d){B=y||null,w.textContent=y?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=y||"",document.getElementById("health-form-id").disabled=!!y,document.getElementById("health-form-name").value=m||"",document.getElementById("health-form-url").value=b||"",document.getElementById("health-form-timeout").value=p||1e4,document.getElementById("health-form-codes").value=u||"200",document.getElementById("health-form-sla").value=s||99.9,document.getElementById("health-form-slow").value=d||5e3,L.style.display="",g.style.display="none"}function A(){L.style.display="none",g.style.display="",B=null}g?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),T?.addEventListener("click",A),k?.addEventListener("click",async()=>{const y=B||document.getElementById("health-form-id").value.trim();if(!y)return showNotification("Service ID is required","warning");const m=document.getElementById("health-form-url").value.trim();if(!m)return showNotification("URL is required","warning");const b=document.getElementById("health-form-codes").value.split(",").map(u=>parseInt(u.trim())).filter(Boolean),p={name:document.getElementById("health-form-name").value.trim()||y,url:m,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:b.length?b:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
|
||||
</div>`;p+="</div>"}else p+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const O=l.success&&l.history?l.history:[];if(O.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of O){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,p+=`<td style="padding: 6px;">${b(_.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,p+=`<td style="padding: 6px;">${q}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,p+="</tr>"}p+="</table>"}P.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),s=n.success&&n.status?Object.values(n.status):[];if(s.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const p of s){const w=p.status==="up";l+='<tr style="border-bottom: 1px solid var(--border);">',l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px; color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"Up":"Down"}</td>`,l+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,l+="</td></tr>"}l+="</table>",z.innerHTML=l}catch(i){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,s,l,p,w,O){c=i||null,x.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=s||"",document.getElementById("health-form-timeout").value=l||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=w||99.9,document.getElementById("health-form-slow").value=O||5e3,B.style.display="",v.style.display="none"}function o(){B.style.display="none",v.style.display="",c=null}v?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),$?.addEventListener("click",o),k?.addEventListener("click",async()=>{const i=c||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const s=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),l={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:s.length?s:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u2B06\uFE0F Update Management</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1505,17 +1543,17 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="updates-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("updates-modal"),S=document.getElementById("updates-btn"),O=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function g(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const o of e){const i=(()=>{const c=window.APPS||[];for(const $ of c)if($.containerId===o.containerId||$.name===o.containerName||$.id===o.containerName)return $.id;return o.containerName})();n+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.containerName)}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(o.imageName)}</td>`,n+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(o.currentDigest)}</code></td>`,n+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(o.latestDigest)}</code></td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button class="update-now-btn" data-id="${escapeHtml(o.containerId)}" data-name="${escapeHtml(o.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,n+=`<button class="rollback-btn" data-id="${escapeHtml(o.containerId)}" data-name="${escapeHtml(o.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,n+="</td></tr>"}n+="</table>",N.innerHTML=n,P.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),l=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),l&&e.length>0&&(l.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.dataset.id,c=o.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){o.textContent="Updating...",o.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(H.success)o.textContent="Done!",o.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Update failed")}catch($){o.textContent="Failed",o.style.color="var(--bad-fg)",showNotification("Update error: "+$.message,"error"),setTimeout(()=>{o.textContent="Update",o.disabled=!1,o.style.color="",o.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.dataset.id,c=o.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){o.textContent="Rolling back...",o.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(H.success)o.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Rollback failed")}catch($){o.textContent="Failed",showNotification("Rollback error: "+$.message,"error"),setTimeout(()=>{o.textContent="Rollback",o.disabled=!1},3e3)}}})})}catch(v){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}async function L(){const v=window._pendingUpdates||[];if(!v.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${v.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,n=0;for(const a of v)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:n++}catch{n++}t.textCLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml($)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${U?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,o+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml($)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,o+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml($)}"${q?" checked":""} /></td>`,o+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,o+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml($)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,o+="</tr>"}o+="</table>",z.innerHTML=o,z.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,$=i.closest("tr"),H=$.querySelector(".auto-schedule").value,U=$.querySelector(".auto-rollback").checked,q=$.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!H,schedule:H||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(G.success)i.textContent="\u2713 Saved";else throw new Error(G.error)}catch(F){i.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(v){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),I=document.getElementById("dashcaddy-update-badge"),C=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),M=document.getElementById("dashcaddy-changelog"),D=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),A=document.getElementById("dashcaddy-rollback-btn"),y=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let b=null;function p(v,t){y&&(y.style.display="block",y.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",y.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",y.textContent=v)}async function u(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;B.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function s(v){v||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(b=e,e.success&&e.available&&e.remote){I.style.display="",C.style.display="",j.textContent="v"+e.remote.version,M.textContent=e.remote.changelog||"No changelog available.";const n=document.getElementById("updates-btn");if(n&&!n.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",n.style.position="relative",n.appendChild(l)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(l)}}else I.style.display="none",C.style.display="none",await u(),v||p("You are running the latest version.","success");v||(x.textContent="Check for Updates",x.disabled=!1)}catch(t){v||(p("Failed to check: "+t.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function d(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;D.textContent="Updating...",D.disabled=!0,p("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return p("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),D.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(v){throw p("Update failed: "+v.message,"error"),D.textContent="Update Now",D.disabled=!1,v}}async function r(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
</div>`);const f=document.getElementById("updates-modal"),I=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),M=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function v(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const s=(()=>{const l=window.APPS||[];for(const p of l)if(p.containerId===n.containerId||p.name===n.containerName||p.id===n.containerName)return p.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",M.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,M.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(w.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(w.error||"Update failed")}catch(p){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+p.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),M.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(w.success)n.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(w.error||"Rollback failed")}catch(p){n.textContent="Failed",showNotification("Rollback error: "+p.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(y){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function B(){const y=window._pendingUpdates||[];if(!y.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml(p)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${O?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(p)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(p)}"${_?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(p)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",z.innerHTML=n,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,p=s.closest("tr"),w=p.querySelector(".auto-schedule").value,O=p.querySelector(".auto-rollback").checked,_=p.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!w,schedule:w||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)s.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const T=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),L=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),N=document.getElementById("dashcaddy-apply-btn"),R=document.getElementById("dashcaddy-check-btn"),U=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let h=null;function g(y,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=y)}async function H(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;T.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{T.textContent="Unable to fetch version"}}async function r(y){y||(R.textContent="Checking...",R.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(h=e,e.success&&e.available&&e.remote){S.style.display="",L.style.display="",j.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else S.style.display="none",L.style.display="none",await H(),y||g("You are running the latest version.","success");y||(R.textContent="Check for Updates",R.disabled=!1)}catch(t){y||(g("Failed to check: "+t.message,"error"),R.textContent="Check for Updates",R.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;N.textContent="Updating...",N.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),N.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(y){throw g("Update failed: "+y.message,"error"),N.textContent="Update Now",N.disabled=!1,y}}async function d(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
`+e.join(`
|
||||
`)+`
|
||||
|
||||
Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification("Invalid version: "+n,"error");return}if(!confirm("Rollback DashCaddy to v"+n+"? The container will restart."))return;p("Rolling back to v"+n+"...","info");const l=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:n})})).json();if(l.success)p("Rollback to v"+n+" initiated. Container will restart.","success");else throw new Error(l.error||"Rollback failed")}catch(v){p("Rollback failed: "+v.message,"error")}}x?.addEventListener("click",()=>s(!1)),D?.addEventListener("click",()=>d().catch(()=>{})),A?.addEventListener("click",f),E?.addEventListener("click",w),S?.addEventListener("click",()=>{h?.classList.add("show"),g()}),wireModal(h,O),window.openUpdateModal=function(v){h?.classList.add("show"),g().then(()=>{if(!v)return;const t=N.querySelector(`[data-app-id="${v}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",T),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{u(),r(),b||s(!0)}),window.dcApplyUpdate=d,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(y){g("Rollback failed: "+y.message,"error")}}R?.addEventListener("click",()=>r(!1)),N?.addEventListener("click",()=>c().catch(()=>{})),U?.addEventListener("click",b),E?.addEventListener("click",x),I?.addEventListener("click",()=>{f?.classList.add("show"),v()}),wireModal(f,A),window.openUpdateModal=function(y){f?.classList.add("show"),v().then(()=>{if(!y)return;const t=M.querySelector(`[data-app-id="${y}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",$),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{H(),d(),h||r(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=r,setTimeout(()=>r(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F433} Docker Resources</h3>
|
||||
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
||||
@@ -1564,7 +1602,7 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
|
||||
<button id="dr-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("docker-resources-modal"),S=document.getElementById("docker-resources-btn"),O=document.getElementById("dr-close");function E(P){if(!P||P===0)return"0 B";const g=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,L)).toFixed(1)+" "+g[L]}async function N(){const P=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const T of L){const k=T.name==="buildkit"||T.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(T.name)}">${escapeHtml(T.name.length>40?T.name.substring(0,37)+"...":T.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',k||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(T.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-vol-del").forEach(T=>{T.addEventListener("click",async()=>{if(confirm(`Delete volume "${T.dataset.name}"? Data will be lost.`)){T.textContent="...",T.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(T.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),T.textContent="Delete",T.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),g=P.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),P.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const T of L){const k=["bridge","host","none"].includes(T.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(T.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${T.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',k||(w+=`<button class="dr-net-del" data-id="${escapeHtml(T.id)}" data-name="${escapeHtml(T.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-net-del").forEach(T=>{T.addEventListener("click",async()=>{if(confirm(`Delete network "${T.dataset.name}"?`)){T.textContent="...",T.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(T.dataset.id)}`),R()}catch(k){showNotification("Delete failed: "+k.message,"error"),T.textContent="Delete",T.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),L=P.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:g.value}),P.value="",showNotification(`Network "${L}" created`,"success"),R()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
|
||||
</div>`);const f=document.getElementById("docker-resources-modal"),I=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function E(D){if(!D||D===0)return"0 B";const v=["B","KB","MB","GB","TB"],B=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,B)).toFixed(1)+" "+v[B]}async function M(){const D=document.getElementById("dr-vol-list");try{const B=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(B.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of B){const k=$.name==="buildkit"||$.name.length===64;x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml($.name)}">${escapeHtml($.name.length>40?$.name.substring(0,37)+"...":$.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-vol-del" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",D.innerHTML=x,D.querySelectorAll(".dr-vol-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete volume "${$.dataset.name}"? Data will be lost.`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent($.dataset.name)}?force=true`),M()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),v=D.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),D.value="",showNotification(`Volume "${v}" created`,"success"),M()}catch(B){showNotification("Create failed: "+B.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const B=(await getJSON("/api/v1/docker/networks")).networks||[];if(B.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of B){const k=["bridge","host","none"].includes($.name);x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml($.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+=`<td style="padding: 6px; text-align: center;">${$.containers}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-net-del" data-id="${escapeHtml($.id)}" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",D.innerHTML=x,D.querySelectorAll(".dr-net-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete network "${$.dataset.name}"?`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent($.dataset.id)}`),P()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),B=D.value.trim();if(!B){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:B,driver:v.value}),D.value="",showNotification(`Network "${B}" created`,"success"),P()}catch(x){showNotification("Create failed: "+x.message,"error")}});async fuLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
||||
<h3>\u{1F4E6} Import Docker Compose</h3>
|
||||
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
||||
@@ -1605,8 +1643,8 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
|
||||
<div id="compose-progress-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("compose-import-modal"),S=document.getElementById("compose-import-btn"),O=document.getElementById("compose-cancel");wireModal(h,O);let E=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}S?.addEventListener("click",()=>{N("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const P=z.target.files[0];if(!P)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),L=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:P});E=w,E.stackName=P,R(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{g.textContent=L,g.disabled=!1}});function R(z){const P=document.getElementById("compose-preview-content");let g="";z.networks&&z.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const L of z.services){const w=L.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),g+="</div>",!L.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(T=>`${T.host}:${T.container}`).join(", ")}</div>`),L.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const T=[];L.resources.cpus&&T.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&T.push(`Mem: ${L.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${T.join(", ")}</div>`}g+="</div>"}g+="</div>",P.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const w of g.results){const T=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${T} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(L+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
||||
</div>`);const f=document.getElementById("compose-import-modal"),I=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(f,A);let E=null;function M(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}I?.addEventListener("click",()=>{M("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",f?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const D=z.target.files[0];if(!D)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),B=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const x=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:D});E=x,E.stackName=D,P(x),M("preview")}catch(x){showNotification("Parse failed: "+x.message,"error")}finally{v.textContent=B,v.disabled=!1}});function P(z){const D=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(B=>`<code>${escapeHtml(B)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const B of z.services){const x=B.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${x}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(B.name)}`,B.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(B.reason)}</span>`),v+="</div>",!B.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(B.image)}</code></div>`,B.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${B.ports.map($=>`${$.host}:${$.container}`).join(", ")}</div>`),B.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${B.volumes.length}</div>`),Object.keys(B.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(B.environment).length}</div>`),B.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(B.envFileWarning)}</div>`),B.resources?.cpus||B.resources?.memory)){const $=[];B.resources.cpus&&$.push(`CPU: ${B.resources.cpus}`),B.resources.memory&&$.push(`Mem: ${B.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${$.join(", ")}</div>`}v+="</div>"}v+="</div>",D.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>M("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,M("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let B=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;B+='<div class="scroll-container" style="max-height: 350px;">';for(const x of v.results){const $=x.status==="deployed"||x.status==="created"?"\u2705":x.status==="exists"?"\u26A1":x.status==="skipped"?"\u23ED":"\u274C";B+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',B+=`${$} <strong>${escapeHtml(x.name)}</strong> (${x.type}) \u2014 ${escapeHtml(x.status)}`,x.error&&(B+=` <span style="color: var(--bad-fg);">${escapeHtml(x.error)}</span>`),x.subdomain&&(B+=` \u2192 <code>${escapeHtml(x.subdomain)}</code>`),x.reason&&(B+=` <span style="color: var(--muted);">(${escapeHtml(x.reason)})</span>`),B+="</div>"}B+="</div>",B+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>M("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
||||
<h3 id="exec-title">Terminal</h3>
|
||||
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
||||
@@ -1614,11 +1652,11 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
|
||||
<button id="exec-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("exec-modal"),S=document.getElementById("exec-terminal"),O=document.getElementById("exec-close");let E=null,N=null,R=null;function z(){if(N){try{N.close()}catch{}N=null}if(E){try{E.dispose()}catch{}E=null}R=null,S.innerHTML=""}function P(g,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||g}`,h?.classList.add("show"),typeof Terminal>"u"){S.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,E.loadAddon(R)),E.open(S),R&&setTimeout(()=>R.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),R){const k=R.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){E.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r
|
||||
`);return}if(B.type==="error"){E.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){E.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},N.onclose=()=>{E&&E.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{E&&E.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),E.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const T=()=>{R&&R.fit()};window.addEventListener("resize",T),h._resizeHandler=T}O?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",g=>{g.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
</div>`);const f=document.getElementById("exec-modal"),I=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let E=null,M=null,P=null;function z(){if(M){try{M.close()}catch{}M=null}if(E){try{E.dispose()}catch{}E=null}P=null,I.innerHTML=""}function D(v,B){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${B||v}`,f?.classList.add("show"),typeof Terminal>"u"){I.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,E.loadAddon(P)),E.open(I),P&&setTimeout(()=>P.fit(),50);const x=location.protocol==="https:"?"wss:":"ws:";M=new WebSocket(`${x}//${location.host}/ws/exec/${encodeURIComponent(v)}`),M.binaryType="arraybuffer",M.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),P){const k=P.proposeDimensions();k&&M.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},M.onmessage=k=>{if(typeof k.data=="string"){try{const T=JSON.parse(k.data);if(T.type==="connected"){E.writeln(`\x1B[32mConnected (${T.shell})\x1B[0m\r
|
||||
`);return}if(T.type==="error"){E.writeln(`\x1B[31mError: ${T.message}\x1B[0m`);return}if(T.type==="exit"){E.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},M.onclose=()=>{E&&E.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},M.onerror=()=>{E&&E.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{M&&M.readyState===WebSocket.OPEN&&M.send(k)}),E.onResize(({cols:k,rows:T})=>{M&&M.readyState===WebSocket.OPEN&&M.send(JSON.stringify({type:"resize",cols:k,rows:T}))});const $=()=>{P&&P.fit()};window.addEventListener("resize",$),f._resizeHandler=$}A?.addEventListener("click",()=>{z(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show")}),f?.addEventListener("click",v=>{v.target===f&&(z(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<h3>\u{1F4DC} Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1654,7 +1692,7 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
|
||||
<button id="audit-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("audit-modal"),S=document.getElementById("audit-log-btn"),O=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let g=0;const L=50;async function w(T){try{T||(g=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=R.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${g}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const C=await(await fetch(B)).json(),j=C.success&&C.entries?C.entries:[];if(j.length===0&&!T){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',P.style.display="none";return}let M="";T||(M='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',M+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const D of j){const x=D.outcome==="success";M+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',M+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(D.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(D.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(D.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(D.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,M+="</tr>",D.details&&Object.keys(D.details).length>0&&(M+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(D.details,null,2))}</pre></td></tr>`)}if(!T)M+="</table>",z.innerHTML=M;else{const D=z.querySelector("table");D&&D.insertAdjacentHTML("beforeend",M)}g+=j.length,P.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(D=>{D.dataset.wired||(D.dataset.wired="true",D.addEventListener("click",()=>{const x=D.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}S?.addEventListener("click",()=>{h?.classList.add("show"),w(!1)}),wireModal(h,O),E?.addEventListener("click",()=>w(!1)),R?.addEventListener("change",()=>w(!1)),P?.addEventListener("click",()=>w(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?w(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(T){showNotification("Error: "+T.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
|
||||
</div>`);const f=document.getElementById("audit-modal"),I=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),M=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),D=document.getElementById("audit-load-more");let v=0;const B=50;async function x($){try{$||(v=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=P.value;let T=`/api/v1/audit-logs?limit=${B}&offset=${v}`;k&&(T+=`&action=${encodeURIComponent(k)}`);const L=await(await fetch(T)).json(),j=L.success&&L.entries?L.entries:[];if(j.length===0&&!$){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',D.style.display="none";return}let C="";$||(C='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',C+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const N of j){const R=N.outcome==="success";C+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',C+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(N.timestamp)}</td>`,C+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(N.ip||"-")}</td>`,C+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(N.action||"-")}</td>`,C+=`<td style="padding: 6px;">${escapeHtml(N.resource||"-")}</td>`,C+=`<td style="padding: 6px;"><span style="color: ${R?"var(--ok-fg)":"var(--bad-fg)"};">${R?"\u2713":"\u2717"}</span></td>`,C+="</tr>",N.details&&Object.keys(N.details).length>0&&(C+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(N.details,null,2))}</pre></td></tr>`)}if(!$)C+="</table>",z.innerHTML=C;else{const N=z.querySelector("table");N&&N.insertAdjacentHTML("beforeend",C)}v+=j.length,D.style.display=j.length>=B?"":"none",z.querySelectorAll(".audit-row").forEach(N=>{N.dataset.wired||(N.dataset.wired="true",N.addEventListener("click",()=>{const R=N.nextElementSibling;R&&R.classList.contains("audit-detail")&&(R.style.display=R.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}I?.addEventListener("click",()=>{f?.classList.add("show"),x(!1)}),wireModal(f,A),E?.addEventListener("click",()=>x(!1)),P?.addEventListener("change",()=>x(!1)),D?.addEventListener("click",()=>x(!0)),M?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?x(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch($){showNotification("Error: "+$.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
|
||||
<h3>\u{1F6E1}\uFE0F Security Center</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1733,39 +1771,39 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
|
||||
<button id="sec-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("security-modal"),S=document.getElementById("security-center-btn"),O=document.getElementById("sec-cancel"),E=h.querySelectorAll(".sec-tab"),N=h.querySelectorAll(".sec-panel");let R=[],z=[],P=null;E.forEach(s=>{s.addEventListener("click",()=>{E.forEach(d=>d.classList.toggle("active",d===s)),N.forEach(d=>d.style.display=d.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&T(),s.dataset.tab==="events"&&D(),s.dataset.tab==="hosts"&&m()})}),S&&S.addEventListener("click",()=>{h.classList.add("show"),T(),L()}),O.addEventListener("click",g),h.addEventListener("click",s=>{s.target===h&&g()});function g(){h.classList.remove("show"),w()}function L(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",s=>{try{R=JSON.parse(s.data).events||[],x()}catch{}}),P.addEventListener("security",s=>{try{const d=JSON.parse(s.data);R.unshift(d),R.length>500&&(R.length=500);const r=h.querySelector(".sec-tab.active")?.dataset?.tab;r==="events"?x():r==="overview"&&T()}catch{}}),P.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&L()});async function T(){try{const s=new Date(Date.now()-864e5).toISOString(),[d,r,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),v=(await d.json()).data||{},t=(await r.json()).data?.hosts||[],e=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${v.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${v.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${v.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",v.top_actors||[]),k("sec-top-targets",v.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function k(s,d){const r=document.getElementById(s);if(!d.length){r.innerHTML='<div class="panel-empty">No data</div>';return}r.innerHTML='<table style="width:100%;font-size:0.85rem;">'+d.map(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${p(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const B=document.getElementById("sec-filter-source"),I=document.getElementById("sec-filter-severity"),C=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),M=document.getElementById("sec-refresh-btn");[B,I,C].forEach(s=>s.addEventListener("change",D)),j.addEventListener("input",u(D,250)),M.addEventListener("click",D);async function D(){try{const s=new URLSearchParams;s.set("limit","200"),B.value&&s.set("source_type",B.value),I.value&&s.set("severity",I.value),C.value&&s.set("source_host",C.value),j.value&&s.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],x(),(!C.options.length||C.options.length===1)&&await y()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}function x(){const s=document.getElementById("sec-events-container");if(!R.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=R.slice(0,200).map(A).join("")}function A(s){const d=s.severity||"info",r={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[d]||"#7f8c8d",f=s.ts?new Date(s.ts).toLocaleTimeString():"",v=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",n=s.action||"",a=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${r};font-weight:600;">${p(d)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${p(v)}</span>
|
||||
<span title="${p(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(t)}</span>
|
||||
<span title="${p(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(n)} ${p(e)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${p(a)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${p(f)}</span>
|
||||
</div>`}async function y(){try{const d=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],r=C.value;C.innerHTML='<option value="">All hosts</option>'+d.map(f=>`<option value="${p(f.id)}">${p(f.label||f.id)}</option>`).join(""),r&&(C.value=r)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",b),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const d=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=d;const r=document.getElementById("sec-hosts-container");if(!d.length){r.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}r.innerHTML=d.map(f=>{const v=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
</div>`);const f=document.getElementById("security-modal"),I=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),E=f.querySelectorAll(".sec-tab"),M=f.querySelectorAll(".sec-panel");let P=[],z=[],D=null;E.forEach(r=>{r.addEventListener("click",()=>{E.forEach(c=>c.classList.toggle("active",c===r)),M.forEach(c=>c.style.display=c.dataset.panel===r.dataset.tab?"":"none"),r.dataset.tab==="overview"&&$(),r.dataset.tab==="events"&&N(),r.dataset.tab==="hosts"&&m()})}),I&&I.addEventListener("click",()=>{f.classList.add("show"),$(),B()}),A.addEventListener("click",v),f.addEventListener("click",r=>{r.target===f&&v()});function v(){f.classList.remove("show"),x()}function B(){if(x(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",r=>{try{P=JSON.parse(r.data).events||[],R()}catch{}}),D.addEventListener("security",r=>{try{const c=JSON.parse(r.data);P.unshift(c),P.length>500&&(P.length=500);const d=f.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?R():d==="overview"&&$()}catch{}}),D.onerror=()=>{}}catch(r){console.warn("[security] SSE failed:",r.message)}}function x(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{f.classList.contains("show")&&B()});async function $(){try{const r=new Date(Date.now()-864e5).toISOString(),[c,d,b]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(r)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(r)}`)]),y=(await c.json()).data||{},t=(await d.json()).data?.hosts||[],e=(await b.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${y.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${y.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${y.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",y.top_actors||[]),k("sec-top-targets",y.top_targets||[])}catch(r){console.warn("[security] refreshOverview failed:",r.message)}}function k(r,c){const d=document.getElementById(r);if(!c.length){d.innerHTML='<div class="panel-empty">No data</div>';return}d.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(b=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(b.key))}</td><td style="text-align:right;color:var(--muted);">${b.count}</td></tr>`).join("")+"</table>"}const T=document.getElementById("sec-filter-source"),S=document.getElementById("sec-filter-severity"),L=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),C=document.getElementById("sec-refresh-btn");[T,S,L].forEach(r=>r.addEventListener("change",N)),j.addEventListener("input",H(N,250)),C.addEventListener("click",N);async function N(){try{const r=new URLSearchParams;r.set("limit","200"),T.value&&r.set("source_type",T.value),S.value&&r.set("severity",S.value),L.value&&r.set("source_host",L.value),j.value&&r.set("actor_prefix",j.value),P=(await(await fetch(`/api/v1/security/events?${r}`)).json()).data.events||[],R(),(!L.options.length||L.options.length===1)&&await u()}catch(r){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}function R(){const r=document.getElementById("sec-events-container");if(!P.length){r.innerHTML='<div class="panel-empty">No events</div>';return}r.innerHTML=P.slice(0,200).map(U).join("")}function U(r){const c=r.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",b=r.ts?new Date(r.ts).toLocaleTimeString():"",y=r.source_type||"",t=r.actor||"\u2014",e=r.target||"",a=r.action||"",o=r.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${d};font-weight:600;">${g(c)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${g(y)}</span>
|
||||
<span title="${g(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(t)}</span>
|
||||
<span title="${g(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(a)} ${g(e)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${g(o)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(b)}</span>
|
||||
</div>`}async function u(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=L.value;L.innerHTML='<option value="">All hosts</option>'+c.map(b=>`<option value="${g(b.id)}">${g(b.label||b.id)}</option>`).join(""),d&&(L.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",h),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=c;const d=document.getElementById("sec-hosts-container");if(!c.length){d.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}d.innerHTML=c.map(b=>{const y=b.enabled?b.last_seen_at?Date.now()-Date.parse(b.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${p(f.label||f.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${p(f.type)}</span>
|
||||
<strong>${g(b.label||b.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(b.type)}</span>
|
||||
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
||||
id: ${p(f.id)} \xB7
|
||||
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
|
||||
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
|
||||
id: ${g(b.id)} \xB7
|
||||
registered ${new Date(b.registered_at).toLocaleDateString()} \xB7
|
||||
last seen ${b.last_seen_at?new Date(b.last_seen_at).toLocaleString():"never"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:0.85rem;margin-right:12px;">${v}</span>
|
||||
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${p(f.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
||||
<span style="font-size:0.85rem;margin-right:12px;">${y}</span>
|
||||
${b.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(b.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
||||
</div>
|
||||
</div>`}).join(""),r.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),m())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}async function b(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const d=prompt("Display label:",s)||s,r=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:d,type:r})}),v=await f.json();if(!f.ok){alert("Failed: "+(v?.error?.message||f.statusText));return}alert(`\u2705 Host registered!
|
||||
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(b=>{b.addEventListener("click",async()=>{confirm(`Remove host ${b.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(b.dataset.id)}`,{method:"DELETE"}),m())})})}catch(r){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}async function h(){const r=prompt("Host id (lowercase, no spaces):");if(!r)return;const c=prompt("Display label:",r)||r,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const b=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:r,label:c,type:d})}),y=await b.json();if(!b.ok){alert("Failed: "+(y?.error?.message||b.statusText));return}alert(`\u2705 Host registered!
|
||||
|
||||
id: ${v.data.host.id}
|
||||
label: ${v.data.host.label}
|
||||
type: ${v.data.host.type}
|
||||
id: ${y.data.host.id}
|
||||
label: ${y.data.host.label}
|
||||
type: ${y.data.host.type}
|
||||
|
||||
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
|
||||
|
||||
${v.data.api_key}
|
||||
${y.data.api_key}
|
||||
|
||||
Send this key as: Authorization: Bearer <api_key>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f){alert("Failed: "+f.message)}}function p(s){return String(s).replace(/[&<>"']/g,d=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[d])}function u(s,d){let r;return function(){clearTimeout(r),r=setTimeout(()=>s.apply(this,arguments),d)}}})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(b){alert("Failed: "+b.message)}}function g(r){return String(r).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c])}function H(r,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>r.apply(this,arguments),c)}}})(),(function(){const f=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
<label for="weather-location-input">Location:</label>
|
||||
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
||||
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
||||
@@ -1776,23 +1814,23 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
|
||||
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const S="weather-location",O="weather-zip",E="weather-geo",N="weather-unit";!safeGet(S)&&safeGet(O)&&safeSet(S,safeGet(O));function R(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(M){return L[Math.round(M/22.5)%16]}async function T(M){const D=safeGet(E);if(D)try{const b=JSON.parse(D);if(b.query===M)return b}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(M)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const A=await x.json();if(!A.results||!A.results.length)throw new Error("Location not found");const y=A.results[0],m={query:M,lat:y.latitude,lon:y.longitude,city:y.name,state:y.admin1||"",country:y.country||"",countryCode:y.country_code||""};return safeSet(E,JSON.stringify(m)),m}function k(M){return M.countryCode==="US"&&M.state?`${M.city}, ${M.state}`:M.country?`${M.city}, ${M.country}`:M.city}async function B(M){try{const D=await T(M),x=R(),A=x==="metric"?"celsius":"fahrenheit",y=x==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${D.lat}&longitude=${D.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${A}&wind_speed_unit=${y}`,b=await fetch(m);if(!b.ok)throw new Error("Weather fetch failed");const u=(await b.json()).current,s=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:P[s]||"Unknown",icon:g[s]||"\u{1F324}\uFE0F",locationStr:k(D),windSpeed:Math.round(u.wind_speed_10m),windDir:w(u.wind_direction_10m),unit:x}}catch(D){return console.warn("Weather fetch failed:",D),null}}async function I(){const M=z();if(!M.icon||!M.temp||!M.condition||!M.location||!M.wind){console.warn("Weather widget elements not found");return}const D=safeGet(S);if(!D){M.location.textContent="Set Location",M.temp.textContent="--\xB0",M.condition.textContent="Click \u2699\uFE0F to configure",M.wind.textContent="--",M.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await B(D);if(x){const A=x.unit==="metric"?"\xB0C":"\xB0F",y=x.unit==="metric"?"km/h":"mph";M.location.textContent=x.locationStr,M.temp.textContent=`${x.temp}${A}`,M.condition.textContent=x.condition,M.wind.textContent=`Wind: ${x.windSpeed} ${y} ${x.windDir}`,M.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),M.location.textContent="Weather Error",M.temp.textContent="Error",M.condition.textContent="Failed to load",M.wind.textContent="--"}}const C=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(S)||"";const M=R(),D=C.querySelector(`input[name="weather-unit-radio"][value="${M}"]`);D&&(D.checked=!0),C.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const M=j.value.trim();if(M){safeGet(S)!==M&&safeSet(E,""),safeSet(S,M);const x=C.querySelector('input[name="weather-unit-radio"]:checked'),A=x?x.value:"imperial",y=R();safeSet(N,A),y!==A&&safeSet(E,""),C.classList.remove("show"),I()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(C),document.addEventListener("keydown",M=>{M.keyLine truncated
|
||||
<circle cx="${i}" cy="${c}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const I="weather-location",A="weather-zip",E="weather-geo",M="weather-unit";!safeGet(I)&&safeGet(A)&&safeSet(I,safeGet(A));function P(){return safeGet(M)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},B=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function x(C){return B[Math.round(C/22.5)%16]}async function $(C){const N=safeGet(E);if(N)try{const h=JSON.parse(N);if(h.query===C)return h}catch{}const R=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!R.ok)throw new Error("Geocoding failed");const U=await R.json();if(!U.results||!U.results.length)throw new Error("Location not found");const u=U.results[0],m={query:C,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(E,JSON.stringify(m)),m}function k(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function T(C){try{const N=await $(C),R=P(),U=R==="metric"?"celsius":"fahrenheit",u=R==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${N.lat}&longitude=${N.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${U}&wind_speed_unit=${u}`,h=await fetch(m);if(!h.ok)throw new Error("Weather fetch failed");const H=(await h.json()).current,r=H.weather_code;return{temp:Math.round(H.temperature_2m),condition:D[r]||"Unknown",icon:v[r]||"\u{1F324}\uFE0F",locationStr:k(N),windSpeed:Math.round(H.wind_speed_10m),windDir:x(H.wind_direction_10m),unit:R}}catch(N){return console.warn("Weather fetch failed:",N),null}}async function S(){const C=z();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const N=safeGet(I);if(!N){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const R=await T(N);if(R){const U=R.unit==="metric"?"\xB0C":"\xB0F",u=R.unit==="metric"?"km/h":"mph";C.location.textContent=R.locationStr,C.temp.textContent=`${R.temp}${U}`,C.condition.textContent=R.condition,C.wind.textContent=`Wind: ${R.windSpeed} ${u} ${R.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(R.icon)}</span>`}}catch(R){f.logError("[Weather] Update Error",R,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const L=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(I)||"";const C=P(),N=L.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);N&&(N.checked=!0),L.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=j.value.trim();if(C){safeGet(I)!==C&&safeSet(E,""),safeSet(I,C);const R=L.querySelector('input[name="weather-unit-radio"]:checked'),U=R?R.value:"imperial",u=P();safeSet(M,U),u!==U&&safeSet(E,""),L.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(L),document.addEventListener("keydown",C=>{C.keyLine truncated
|
||||
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
${F}
|
||||
${q}
|
||||
<line x1="${i}" y1="${c}" x2="${i+28*Math.cos(U*Math.PI/180)}" y2="${c+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${i}" y1="${c}" x2="${i+38*Math.cos(H*Math.PI/180)}" y2="${c+38*Math.sin(H*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${i}" y1="${c}" x2="${i+42*Math.cos($*Math.PI/180)}" y2="${c+42*Math.sin($*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${i}" cy="${c}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";S.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${B(t)}</span></div></div>`,L="analog"}function m(){const t=new Date,e=t.getHours()%12||12,n=t.getMinutes(),a=t.getSeconds(),l="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==l&&(h.className=l),R){case"lcd":M(t);break;case"lcd-blue":M(t);break;case"lcd-amber":M(t);break;case"lcd-retro":M(t);break;case"lcd-taxi":M(t);break;case"flip":D(t);break;case"binary":A(t);break;case"analog":y(t,!1);break;case"roman":y(t,!0);break;default:j(t)}n===0&&a===0&&e!==z&&(z=e,k(e)),n!==0&&(z=-1)}function b(){clearTimeout(T);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;T=setTimeout(()=>{m(),b()},e)}document.addEventListener("visibilitychange",()=>{g="",I(),m(),b()}),m(),b();const p=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let u='<div class="clock-style-grid">';p.forEach(t=>{u+=`<label class="clock-style-option">
|
||||
${_}
|
||||
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(O*Math.PI/180)}" y2="${l+28*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(w*Math.PI/180)}" y2="${l+38*Math.sin(w*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(p*Math.PI/180)}" y2="${l+42*Math.sin(p*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";I.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${T(t)}</span></div></div>`,B="analog"}function m(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(f.className!==i&&(f.className=i),P){case"lcd":C(t);break;case"lcd-blue":C(t);break;case"lcd-amber":C(t);break;case"lcd-retro":C(t);break;case"lcd-taxi":C(t);break;case"flip":N(t);break;case"binary":U(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:j(t)}a===0&&o===0&&e!==z&&(z=e,k(e)),a!==0&&(z=-1)}function h(){clearTimeout($);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;$=setTimeout(()=>{m(),h()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),m(),h()}),m(),h();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let H='<div class="clock-style-grid">';g.forEach(t=>{H+=`<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${t.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
|
||||
</label>`}),u+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
</label>`}),H+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="max-width: 420px;">
|
||||
<h3>Clock Settings</h3>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
||||
${u}
|
||||
${H}
|
||||
</div>
|
||||
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
||||
@@ -1816,7 +1854,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
|
||||
<button id="clock-settings-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const s=document.getElementById("clock-settings-modal"),d=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function v(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),d.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",f.style.opacity=d.checked?"1":"0.4"}d?.addEventListener("change",()=>{f.style.opacity=d.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{v(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(r.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(d.checked)),safeSet("clock-chime-volume",r.value),R=e,g="",I(),m(),b(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{R=t.value,g="",I(),m()})})})(),(function(){async function h(){try{const P=await(await fetch("/api/v1/health-checks/status")).json();if(!P.success||!P.status)return;for(const[g,L]of Object.entries(P.status)){const w=document.getElementById("uptime-"+g),T=document.getElementById("uptime-bar-"+g);if(!w)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);w.textContent=`${B}% uptime`,w.className="uptime-chip",k>=99.9?w.classList.add("excellent"):k>=99?w.classList.add("good"):k>=95?w.classList.add("degraded"):w.classList.add("poor"),T&&(T.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let S;try{S=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{S=new Set}let O=[];async function E(z){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const L=g.updates||[];if(O=L,z&&L.length>0){const w=window._lastKnownUpdateCount||0;w>0&&L.length>w&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const w of L){const T=window.APPS||[];for(const k of T)if(k.containerId===w.containerId||k.id===w.containerName||k.name===w.containerName){if(S.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),I=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.onclick=C=>{C.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),I&&(I.style.background="#f97316",I.style.borderColor="#f97316",I.style.boxShadow="0 0 6px #f9731688",I.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),E()},5e3),setInterval(()=>{h(),E(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,S=null,O={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var z=document.getElementById("theme-label");function P(a){if(E[a])return E[a];var l=safeGetJSON(window.USER_THEMES_KEY,{});return l[a]&&l[a].name||a}function g(){z&&(z.textContent=P(window.getActiveTheme()))}R.addEventListener("click",function(){var a=window.THEMES.slice(),l=window.getActiveTheme(),o=a.indexOf(l),i=a[(o+1)%a.length];window.applyTheme(i),g()}),g();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),b=document.getElementById("clock-volume-section");function y(){const t=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",b.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{b.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(d.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=r.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=e,v="",S(),m(),h(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,v="",S(),m()})})})(),(function(){async function f(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[v,B]of Object.entries(D.status)){const x=document.getElementById("uptime-"+v),$=document.getElementById("uptime-bar-"+v);if(!x)continue;const k=B.uptime?.["24h"];if(k!=null){const T=k.toFixed(1);x.textContent=`${T}% uptime`,x.className="uptime-chip",k>=99.9?x.classList.add("excellent"):k>=99?x.classList.add("good"):k>=95?x.classList.add("degraded"):x.classList.add("poor"),$&&($.style.width=T+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let I;try{I=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{I=new Set}let A=[];async function E(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(x=>x.classList.remove("visible"));const B=v.updates||[];if(A=B,z&&B.length>0){const x=window._lastKnownUpdateCount||0;x>0&&B.length>x&&showNotification(`${B.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=B.length}if(!B.length)return;for(const x of B){const $=window.APPS||[];for(const k of $)if(k.containerId===x.containerId||k.id===x.containerName||k.name===x.containerName){if(I.has(k.id))break;const T=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);T&&(T.classList.add("visible"),T.title="Update available \u2014 click to open Update Management.",T.style.cursor="pointer",T.onclick=L=>{L.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function M(){setTimeout(()=>{f(),E()},5e3),setInterval(()=>{f(),E(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(f,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),M()})(),(function(){var f=null,I=null,A={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},M=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var z=document.getElementById("theme-label");function D(o){if(E[o])return E[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function v(){z&&(z.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),s=o[(n+1)%o.length];window.applyTheme(s),v()}),v();function B(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>DashCaddy License</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
@@ -1853,16 +1891,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
|
||||
<button id="license-activate" class="btn-accent">Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("license-modal"),S=document.getElementById("license-code-input"),O=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),R=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),P=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),L=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),T=document.getElementById("license-activate-section");let k=null;function B(){N.style.display="none",R.style.display="none"}function I(b){B(),N.textContent=b,N.style.display="block"}function C(b){B(),R.textContent=b,R.style.display="block"}function j(b){if(k=b,b.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",z.textContent="\u2605",P.textContent="Premium Active";const s=b.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(b.expiresAt).toLocaleDateString()}</strong> (${b.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${b.code||"***"}</code></div>
|
||||
${s}
|
||||
`,T.style.display="none",O.style.display="none",E.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",z.textContent="\u2606",P.textContent=b.expired?"License Expired":"Free Tier",L.innerHTML=b.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",T.style.display="",O.style.display="",E.style.display="none";const p=b.premiumFeatures||{},u=new Set(b.features||[]);w.innerHTML=Object.entries(p).map(([s,d])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${u.has(s)?"\u2705":"\u{1F512}"}</span>
|
||||
</div>`);const f=document.getElementById("license-modal"),I=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),M=document.getElementById("license-error"),P=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),B=document.getElementById("license-details"),x=document.getElementById("license-feature-list"),$=document.getElementById("license-activate-section");let k=null;function T(){M.style.display="none",P.style.display="none"}function S(h){T(),M.textContent=h,M.style.display="block"}function L(h){T(),P.textContent=h,P.style.display="block"}function j(h){if(k=h,h.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",z.textContent="\u2605",D.textContent="Premium Active";const r=h.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(h.expiresAt).toLocaleDateString()}</strong> (${h.daysRemaining} days remaining)</div>`;B.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${h.code||"***"}</code></div>
|
||||
${r}
|
||||
`,$.style.display="none",A.style.display="none",E.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",z.textContent="\u2606",D.textContent=h.expired?"License Expired":"Free Tier",B.innerHTML=h.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",$.style.display="",A.style.display="",E.style.display="none";const g=h.premiumFeatures||{},H=new Set(h.features||[]);x.innerHTML=Object.entries(g).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${H.has(r)?"\u2705":"\u{1F512}"}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${d.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${d.description}</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
|
||||
</div>
|
||||
</div>`).join("")}async function M(){try{const p=await(await fetch("/api/v1/license/status")).json();p.success&&(j(p.license),A(p.license))}catch(b){console.warn("Failed to load license status:",b.message)}}async function D(){const b=S.value.trim();if(!b){I("Please enter a license code.");return}B(),O.disabled=!0,O.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();u.success?(C(u.message),S.value="",j(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),A(u.license)):I(u.error||"Activation failed")}catch(p){I("Network error: "+p.message)}finally{O.disabled=!1,O.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const p=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();p.success?(C(p.message),await M(),showNotification("License deactivated.","info",3e3),A({active:!1})):I(p.error||"Deactivation failed")}catch(b){I("Network error: "+b.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function A(b){const p=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),d=document.getElementById("license-topbar-time");if(p)if(p.className="license-status-topbar "+(b.active?"premium":"free"),b.active)if(u.textContent="\u2605",s.textContent="PREMIUM",b.lifetime)d.textContent="\xB7 LIFETIME";else{const r=b.daysRemaining;d.textContent=r!=null?"\xB7 "+r+"d remaining":""}else u.textContent="\u2606",s.textContent=b.expired?"EXPIRED":"FREE TIER",d.textContent=""}function y(){B(),M(),h.classList.add("show")}S.addEventListener("input",function(){let b=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(b.length>this._prevLength&&(b=b.replace(/-/g,""),b.length>2&&!b.startsWith("DC")&&(b="DC"+b),b.startsWith("DC")&&b.length>2)){const p=["DC"],u=b.substring(2);for(let s=0;s<u.length;s+=5)p.push(u.substring(s,s+5));b=p.join("-")}this._prevLength=b.length,this.value=b}),O.addEventListener("click",D),E.addEventListener("click",x),S.addEventListener("keydown",b=>{b.key==="Enter"&&D()}),wireModal(h,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=y,window.checkPremiumFeature=async function(b){try{return(await(await fetch(`/api/v1/license/feature/${b}`)).json()).available}catch{return!1}},M().then(b=>{k&&A(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const h=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
|
||||
</div>`).join("")}async function C(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(j(g.license),U(g.license))}catch(h){console.warn("Failed to load license status:",h.message)}}async function N(){const h=I.value.trim();if(!h){S("Please enter a license code.");return}T(),A.disabled=!0,A.textContent="Activating...";try{const H=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();H.success?(L(H.message),I.value="",j(H.license),showNotification("License activated! Premium features unlocked.","success",5e3),U(H.license)):S(H.error||"Activation failed")}catch(g){S("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function R(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(L(g.message),await C(),showNotification("License deactivated.","info",3e3),U({active:!1})):S(g.error||"Deactivation failed")}catch(h){S("Network error: "+h.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function U(h){const g=document.getElementById("license-status-topbar"),H=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(h.active?"premium":"free"),h.active)if(H.textContent="\u2605",r.textContent="PREMIUM",h.lifetime)c.textContent="\xB7 LIFETIME";else{const d=h.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else H.textContent="\u2606",r.textContent=h.expired?"EXPIRED":"FREE TIER",c.textContent=""}function u(){T(),C(),f.classList.add("show")}I.addEventListener("input",function(){let h=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(h.length>this._prevLength&&(h=h.replace(/-/g,""),h.length>2&&!h.startsWith("DC")&&(h="DC"+h),h.startsWith("DC")&&h.length>2)){const g=["DC"],H=h.substring(2);for(let r=0;r<H.length;r+=5)g.push(H.substring(r,r+5));h=g.join("-")}this._prevLength=h.length,this.value=h}),A.addEventListener("click",N),E.addEventListener("click",R),I.addEventListener("keydown",h=>{h.key==="Enter"&&N()}),wireModal(f,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(h){try{return(await(await fetch(`/api/v1/license/feature/${h}`)).json()).available}catch{return!1}},C().then(h=>{k&&U(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const f=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
|
||||
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
|
||||
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
|
||||
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
|
||||
@@ -1884,7 +1922,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
|
||||
<div class="share-tab-panel" data-panel="public">
|
||||
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
|
||||
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
|
||||
${h.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
|
||||
${f.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
|
||||
</select>
|
||||
<button id="share-public-create" class="btn-accent" type="button"
|
||||
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
||||
@@ -1931,16 +1969,17 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);const S=document.getElementById("share-modal"),O=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),R=document.getElementById("share-issued-copy"),z=document.getElementById("share-issued-meta"),P=document.getElementById("share-error"),g=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),T=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),B=document.getElementById("share-ts-email"),I=document.getElementById("share-public-ttl");let C=null,j="public";function M(){P.style.display="none",g.style.display="none",E.style.display="none"}function D(t){g.style.display="none",E.style.display="none",P.textContent=t,P.style.display="block"}function x(t){P.style.display="none",g.textContent=t,g.style.display="block"}function A(){return window.location.origin}function y(t){return t.startsWith("http")?t:A()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function b(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const n=Math.floor(t%36e5/6e4);return`${e}h ${n}m left`}function p(t){j=t,M(),S.querySelectorAll(".share-tab").forEach(e=>{const n=e.dataset.tab===t;e.classList.toggle("active",n),e.style.borderBottomColor=n?"var(--accent)":"transparent",e.style.color=n?"var(--fg)":"var(--muted)",e.style.fontWeight=n?"600":"400"}),S.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function u(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),a=(e&&e.data||[]).filter(l=>l.serviceId===C.id);if(a.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=a.map(l=>{const o=l.expiresAt?b(new Date(l.expiresAt).getTime()-Date.now()):"";return`
|
||||
<div class="share-row" data-share-id="${escapeHtml(l.id)}"
|
||||
`);const I=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),M=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),z=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),v=document.getElementById("share-success"),B=document.getElementById("share-outstanding-list"),x=document.getElementById("share-cancel"),$=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),T=document.getElementById("share-ts-email"),S=document.getElementById("share-public-ttl");let L=null,j="public";function C(){D.style.display="none",v.style.display="none",E.style.display="none"}function N(t){v.style.display="none",E.style.display="none",D.textContent=t,D.style.display="block"}function R(t){D.style.display="none",v.textContent=t,v.style.display="block"}function U(){return window.location.origin}function u(t){return t.startsWith("http")?t:U()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function h(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){j=t,C(),I.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),I.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function H(){B.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){B.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===L.id);if(o.length===0){B.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}B.innerHTML=o.map(i=>{const n=i.expiresAt?h(new Date(i.expiresAt).getTime()-Date.now()):"";return`
|
||||
<div class="share-row" data-share-id="${escapeHtml(i.id)}"
|
||||
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
||||
<span style="flex: 1;">
|
||||
<strong>${l.kind==="tailscale"?"Tailscale":"Public"}</strong>
|
||||
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(l.expiresAt))} (${o})</span>
|
||||
<strong>${i.kind==="tailscale"?"Tailscale":"Public"}</strong>
|
||||
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(i.expiresAt))} (${n})</span>
|
||||
</span>
|
||||
<button class="share-revoke" data-share-id="${escapeHtml(l.id)}" type="button"
|
||||
<button class="share-revoke" data-share-id="${escapeHtml(i.id)}" type="button"
|
||||
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
`}).join(""),L.querySelectorAll(".share-revoke").forEach(l=>{l.addEventListener("click",()=>s(l.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const n=await e.json().catch(()=>({}));D(n&&n.error||`Revoke failed (HTTP ${e.status}).`);return}x("Share revoked."),u()}catch{D("Network error while revoking.")}}async function d(){if(!C)return;M(),T.disabled=!0;const t=T.textContent;T.textContent="Creating\u2026";try{const e=parseInt(I.value,10),n=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,ttlMs:e})}),a=await n.json().catch(()=>({}));if(!n.ok||!a.success){f(n.status,a);return}const l=a.data.urlPath;N.value=y(l),z.textContent=`Public link \xB7 expires ${m(a.data.expiresAt)}`,E.style.display="block",x("Share link created."),u()}catch{D("Network error while creating share.")}finally{T.disabled=!1,T.textContent=t}}async function r(){if(!C)return;const t=(B.value||"").trim();if(!t||!t.includes("@")){D("A valid recipient email is required.");return}M(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const n=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,email:t})}),a=await n.json().catch(()=>({}));if(!n.ok||!a.success){f(n.status,a);return}const l=a.data&&a.data.urlPath;l?(N.value=y(l),z.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(N.value="",z.textContent=`Tailscale invite sent to ${t}.`),E.style.display="block",x("Tailscale invite created."),B.value="",u()}catch{D("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function f(t,e){const n=e&&e.error||"";if(t===402){D("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(n)){D("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){D("You do not have permission to share services.");return}if(t===404){D("Service not found. It may have been deleted.");return}D(n||`Request failed (HTTP ${t}).`)}function v(t){!t||!t.id||(C={id:t.id,name:t.name||t.id},O.textContent=C.name,M(),p("public"),B.value="",I.value=String(h[1].ms),S.classList.add("show"),u())}S.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>p(t.dataset.tab))}),T.addEventListener("click",d),k.addEventListener("click",r),w.addEventListener("click",()=>S.classList.remove("show")),wireModal(S,w),R.addEventListener("click",async()=>{const t=N.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(N.select(),document.execCommand("copy"));const e=R.textContent;R.textContent="Copied!",setTimeout(()=>{R.textContent=e},1200)}catch{D("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=v})();
|
||||
`}).join(""),B.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>r(i.dataset.shareId))})}catch{B.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function r(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));N(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}R("Share revoked."),H()}catch{N("Network error while revoking.")}}async function c(){if(!L)return;C(),$.disabled=!0;const t=$.textContent;$.textContent="Creating\u2026";try{const e=parseInt(S.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:L.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){b(a.status,o);return}const i=o.data.urlPath;M.value=u(i),z.textContent=`Public link \xB7 expires ${m(o.data.expiresAt)}`,E.style.display="block",R("Share link created."),H()}catch{N("Network error while creating share.")}finally{$.disabled=!1,$.textContent=t}}async function d(){if(!L)return;const t=(T.value||"").trim();if(!t||!t.includes("@")){N("A valid recipient email is required.");return}C(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:L.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){b(a.status,o);return}const i=o.data&&o.data.urlPath;i?(M.value=u(i),z.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(M.value="",z.textContent=`Tailscale invite sent to ${t}.`),E.style.display="block",R("Tailscale invite created."),T.value="",H()}catch{N("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function b(t,e){const a=e&&e.error||"";if(t===402){N("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){N("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){N("You do not have permission to share services.");return}if(t===404){N("Service not found. It may have been deleted.");return}N(a||`Request failed (HTTP ${t}).`)}function y(t){!t||!t.id||(L={id:t.id,name:t.name||t.id},A.textContent=L.name,C(),g("public"),T.value="",S.value=String(f[1].ms),I.classList.add("show"),H())}I.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),$.addEventListener("click",c),k.addEventListener("click",d),x.addEventListener("click",()=>I.classList.remove("show")),wireModal(I,x),P.addEventListener("click",async()=>{const t=M.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(M.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{N("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=y})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc
Line truncated
|
||||
Vendored
+2
-1
@@ -1,4 +1,4 @@
|
||||
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(n.success&&Line truncated
|
||||
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(()=>{window._dcAuthLost||window.refreshAll()},DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-stoLine truncated
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -329,3 +329,4 @@
|
||||
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKCgpID0+IHtcbiAgICAgIC8vIFN0b3AgcG9sbGluZyBpZiB0aGUgc2Vzc2lvbiBoYXMgYmVlbiBpbnZhbGlkYXRlZCAoZS5nLiBUT1RQIGdhdGVcbiAgICAgIC8vIG5vdyBhY3RpdmUsIG9yIHVzZXIgbG9nZ2VkIG91dCkuIEF2b2lkcyByZWxlbnRsZXNzIDQwMS80MDMgbm9pc2UuXG4gICAgICBpZiAod2luZG93Ll9kY0F1dGhMb3N0KSByZXR1cm47XG4gICAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIH0sIERDLlBPTEwuREFTSEJPQVJEKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoQ3JlZHNCdXR0b25zID09PSAnZnVuY3Rpb24nKSB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucygpO1xuICAgIGlmICh0eXBlb2Ygd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hNb25pdG9yaW5nV2lkZ2V0cygpO1xuICAgIC8vIFVwZGF0ZSBhdXRoIGNhcmQgKG1heSBoYXZlIGFscmVhZHkgYmVlbiB1cGRhdGVkIGJ5IHRoZSBhdXRvLWxvYWQgSUlGRSBidXQgZW5zdXJlIGl0J3MgY29ycmVjdClcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQgPT09ICdmdW5jdGlvbicpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHIgPSBhd2FpdCBmZXRjaCgnL2FwaS92MS90b3RwL2NvbmZpZycsIHsgY2FjaGU6ICduby1zdG9yZScgfSk7XG4gICAgICAgIGNvbnN0IGQgPSBhd2FpdCByLmpzb24oKTtcbiAgICAgICAgaWYgKGQuc3VjY2Vzcykgd2luZG93Ll91cGRhdGVBdXRoQ2FyZChkLmNvbmZpZy5lbmFibGVkICYmIGQuY29uZmlnLmlzU2V0VXAsIGQuY29uZmlnLnNlc3Npb25EdXJhdGlvbik7XG4gICAgICB9IGNhdGNoIChlKSB7IC8qIGlnbm9yZSAqLyB9XG4gICAgfVxuICAgIGlmICh3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuX19kYXNoY2FkZHlTaXRlQ29uZmlnTG9hZGVkO1xuICAgICAgfSBjYXRjaCAoXykgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICAvLyBMYXp5LWxvYWQgb25ib2FyZGluZyBvbmx5IG9uY2UgcGVyIGluc3RhbGwsIG90aGVyd2lzZSBqdXN0IGFkZCB0aGUgdG91ciBidXR0b25cbiAgICBhZGRUb3VyQnV0dG9uKCk7XG4gICAgaWYgKHNob3VsZExvYWRPbmJvYXJkaW5nKCkpIHtcbiAgICAgIGxvYWRPbmJvYXJkaW5nKCk7XG4gICAgfVxuXG4gICAgLy8gREMtMDQ4OiBpbmplY3QgdGhlIFwiQWRtaW5cIiB0cmlnZ2VyIGJ1dHRvbiBpbnRvIHRoZSB0b3AgYmFyLiBUaGVcbiAgICAvLyBidXR0b24gb25seSByZW5kZXJzIHdoZW4gL21lIHJldHVybnMgaXNBZG1pbj10cnVlOyB0aGUgbW9kdWxlXG4gICAgLy8gcmUtY2hlY2tzIGV2ZXJ5IDYwcyBzbyBhI
Line truncated
|
||||
Vendored
+1
@@ -198,3 +198,4 @@ ${b}`)}}y.TooltipValidation={validateTooltipDefinition:a,validateTooltipDefiniti
|
||||
Possible issues:
|
||||
- Driver.js library failed to load
|
||||
- JavaScript errors during initialization`))},l=document.getElementById("restart-tour-btn");if(l){l.onclick=C;return}const u=document.createElement("button");u.id="restart-tour-btn",u.textContent="Help Tour",u.title="Restart the onboarding tour",u.onclick=C,m.appendChild(u)}function A(){return typeof(window.driver?.js?.driver||window.driver?.driver||window.driver)!="function"?(console.warn("[Onboarding] Driver.js not loaded yet, will retry... window.driver:",window.driver),!1):!0}function k(){let m=0;const C=10;function l(){A()?n():(m++,m<C?setTimeout(l,500):r?r.handleDriverLoadFailure():new ErrorHandler().handleDriverLoadFailure())}l()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",k):k(),y("[Onboarding] System loaded")})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsidGhpcy5kcml2ZXI9dGhpcy5kcml2ZXJ8fHt9O3RoaXMuZHJpdmVyLmpzPWZ1bmN0aW9uKEQpe1widXNlIHN0cmljdFwiO2xldCBGPXt9O2Z1bmN0aW9uIHooZT17fSl7Rj17YW5pbWF0ZTohMCxhbGxvd0Nsb3NlOiEwLG92ZXJsYXlPcGFjaXR5Oi43LHNtb290aFNjcm9sbDohMSxkaXNhYmxlQWN0aXZlSW50ZXJhY3Rpb246ITEsc2hvd1Byb2dyZXNzOiExLHN0YWdlUGFkZGluZzoxMCxzdGFnZVJhZGl1czo1LHBvcG92ZXJPZmZzZXQ6MTAsc2hvd0J1dHRvbnM6W1wibmV4dFwiLFwicHJldmlvdXNcIixcImNsb3NlXCJdLGRpc2FibGVCdXR0b25zOltdLG92ZXJsYXlDb2xvcjpcIiMwMDBcIiwuLi5lfX1mdW5jdGlvbiBhKGUpe3JldHVybiBlP0ZbZV06Rn1mdW5jdGlvbiBXKGUsbyx0LGkpe3JldHVybihlLz1pLzIpPDE/dC8yKmUqZStvOi10LzIqKC0tZSooZS0yKS0xKStvfWZ1bmN0aW9uIHEoZSl7Y29uc3Qgbz0nYVtocmVmXTpub3QoW2Rpc2FibGVkXSksIGJ1dHRvbjpub3QoW2Rpc2FibGVkXSksIHRleHRhcmVhOm5vdChbZGlzYWJsZWRdKSwgaW5wdXRbdHlwZT1cInRleHRcIl06bm90KFtkaXNhYmxlZF0pLCBpbnB1dFt0eXBlPVwicmFkaW9cIl06bm90KFtkaXNhYmxlZF0pLCBpbnB1dFt0eXBlPVwiY2hlY2tib3hcIl06bm90KFtkaXNhYmxlZF0pLCBzZWxlY3Q6bm90KFtkaXNhYmxlZF0pJztyZXR1cm4gZS5mbGF0TWFwKHQ9Pntjb25zdCBpPXQubWF0Y2hlcyhvKSxwPUFycmF5LmZyb20odC5xdWVyeVNlbGVjdG9yQWxsKG8pKTtyZXR1cm5bLi4uaT9bdF06W10sLi4ucF19KS5maWx0ZXIodD0+Z2V0Q29tcHV0ZWRTdHlsZSh0KS5wb2ludGVyRXZlbnRzIT09XCJub25lXCImJmFlKHQpKX1mdW5jdGlvbiBWKGUpe2lmKCFlfHxzZShlKSlyZXR1cm47Y29uc3Qgbz1hKFwic21vb3RoU2Nyb2xsXCIpO2Uuc2Nyb2xsSW50b1ZpZXcoe2JlaGF2aW9yOiFvfHxyZShlKT9cImF1dG9cIjpcInNtb290aFwiLGlubGluZTpcImNlbnRlclwiLGJsb2NrOlwiY2VudGVyXCJ9KX1mdW5jdGlvbiByZShlKXtpZighZXx8IWUucGFyZW50RWxlbWVudClyZXR1cm47Y29uc3Qgbz1lLnBhcmVudEVsZW1lbnQ7cmV0dXJuIG8uc2Nyb2xsSGVpZ2h0Pm8uY2xpZW50SGVpZ2h0fWZ1bmN0aW9uIHNlKGUpe2NvbnN0IG89ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKTtyZXR1cm4gby50b3A+PTAmJm8ubGVmdD49MCYmby5ib3R0b208PSh3aW5kb3cuaW5uZXJIZWlnaHR8fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRIZWlnaHQpJiZvLnJpZ2h0PD0od2luZG93LmlubmVyV2lkdGh8fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRXaWR0aCl9ZnVuY3Rpb24gYWUoZSl7cmV0dXJuISEoZS5vZmZzZXRXaWR0aHx8ZS5vZmZzZXRIZWlnaHR8fGUuZ2V0Q2xpZW50UmVjdHMoKS5sZW5ndGgpfWxldCBOPXt9O2Z1bmN0aW9uIGIoZSxvKXtOW2VdPW99ZnVuY3Rpb24gbChlKXtyZXR1cm4gZT9OW2VdOk59ZnVuY3Rpb24gSygpe049e319bGV0IEU9e307ZnVuY3Rpb24gTyhlLG8pe0VbZV09b31mdW5jdGlvbiBfKGUpe3ZhciBvOyhvPUVbZV0pPT1udWxsfHxvLmNhbGwoRSl9ZnVuY3Rpb24gY2UoKXtFPXt9fWZ1bmN0aW9uIGxlKGUsbyx0LGkpe2xldCBwPWwoXCJfX2FjdGl2ZVN0YWdlUG9zaXRpb25cIik7Y29uc3Qgbj1wfHx0LmdldEJvdW5kaW5nQ2xpZW50UmVjdCgpLGY9aS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKSx3PVcoZSxuLngsZi54LW4ueCxvKSxyPVcoZSxuLnksZi55LW4ueSxvKSx2PVcoZSxuLndpZHRoLGYud2lkdGgtbi53aWR0aCxvKSxzPVcoZSxuLmhlaWdodCxmLmhlaWdodC1uLmhlaWdodCxvKTtwPXt4OncseTpyLHdpZHRoOnYsaGVpZ2h0OnN9LFkocCksYihcIl9fYWN0aXZlU3RhZ2VQb3NpdGlvblwiLHApfWZ1bmN0aW9uIFgoZSl7aWYoIWUpcmV0dXJuO2NvbnN0IG89ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKSx0PXt4Om8ueCx5Om8ueSx3aWR0aDpvLndpZHRoLGhlaWdodDpvLmhlaWdodH07YihcIl9fYWN0aXZlU3RhZ2VQb3NpdGlvblwiLHQpLFkodCl9ZnVuY3Rpb24gZGUoKXtjb25zdCBlPWwoXCJfX2FjdGl2ZVN0YWdlUG9zaXRpb25cIiksbz1sKFwiX19vdmVybGF5U3ZnXCIpO2lmKCFlKXJldHVybjtpZighbyl7Y29uc29sZS53YXJuKFwiTm8gc3RhZ2Ugc3ZnIGZvdW5kLlwiKTtyZXR1cm59Y29uc3QgdD13aW5kb3cuaW5uZXJXaWR0aCxpPXdpbmRvdy5pbm5lckhlaWdodDtvLnNldEF0dHJpYnV0ZShcInZpZXdCb3hcIixgMCAwICR7dH0gJHtpfWApfWZ1bmN0aW9uIHBlKGUpe2NvbnN0IG89dWUoZSk7ZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChvKSxHKG8sdD0+e3QudGFyZ2V0LnRhZ05hbWU9PT1cInBhdGhcIiYmXyhcIm92ZXJsYXlDbGlja1wiKX0pLGIoXCJfX292ZXJsYXlTdmdcIixvKX1mdW5jdGlvbiBZKGUpe2NvbnN0IG89bChcIl9fb3ZlcmxheVN2Z1wiKTtpZighbyl7cGUoZSk7cmV0dXJufWNvbnN0IHQ9by5maXJzdEVsZW1lbnRDaGlsZDtpZigodD09bnVsbD92b2lkIDA6dC50YWdOYW1lKSE9PVwicGF0aFwiKXRocm93IG5ldyBFcnJvcihcIm5vIHBhdGggZWxlbWVudCBmb3VuZCBpbiBzdGFnZSBzdmdcIik7dC5zZXRBdHRyaWJ1dGUoXCJkXCIsaihlKSl9ZnVuY3Rpb24gdWUoZSl7Y29uc3Qgbz13aW5kb3cuaW5uZXJXaWR0aCx0PXdpbmRvdy5pbm5lckhlaWdodCxpPWRvY3VtZW50LmNyZWF0ZUVsZW1lbnROUyhcImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIsXCJzdmdcIik7aS5jbGFzc0xpc3QuYWRkKFwiZHJpdmVyLW92ZXJsYXlcIixcImRyaXZlci1vdmVybGF5LWFuaW1hdGVkXCIpLGkuc2V0QXR0cmlidXRlKFwidmlld0JveFwiLGAwIDAgJHtvfSAke3R9YCksaS5zZXRBdHRyaWJ1dGUoXCJ4bWxTcGFjZVwiLFwicHJlc2VydmVcIiksaS5zZXRBdHRyaWJ1dGUoXCJ4bWxuc1hsaW5rXCIsXCJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rXCIpLGkuc2V0QXR0cmlidXRlKFwidmVyc2lvblwiLFwiMS4xXCIpLGkuc2V0QXR0cmlidXRlKFwicHJlc2VydmVBc3BlY3RSYXRpb1wiLFwieE1pbllNaW4gc2xpY2VcIiksaS5zdHlsZS5maWxsUnVsZT1cImV2ZW5vZGRcIixpLnN0eWxlLmNsaXBSdWxlPVwiZXZlbm9kZFwiLGkuc3R5bGUuc3Ryb2tlTGluZWpvaW49XCJyb3VuZFwiLGkuc3R5bGUuc3Ryb2tlTWl0ZXJsaW1pdD1cIjJcIixpLnN0eWxlLnpJbmRleD1cIjEwMDAwXCIsaS5zdHlsZS5wb3NpdGlvbj1cImZpeGVkXCIsaS5zdHlsZS50b3A9XCIwXCIsaS5zdHlsZS5sZWZ0PVwiMFwiLGkuc3R5bGUud2lkdGg9XCIxMDAlXCIsaS5zdHlsZS5oZWlnaHQ9XCIxMDAlXCI7Y29uc3QgcD1kb2N1bWVudC5jcmVhdGVFbGVtZW50TlMoXCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiLFwicGF0aFwiKTtyZXR1cm4gcC5zZXRBdHRyaWJ1dGUoXCJkXCIsaihlKSkscC5zdHlsZS5maWxsPWEoXCJvdmVybGF5Q29sb3JcIil8fFwicmdiKDAsMCwwKVwiLHAuc3R5bGUub3BhY2l0eT1gJHthKFwib3ZlcmxheU9wYWNpdHlcIil9YCxwLnN0eWxlLnBvaW50ZXJFdmVudHM9XCJhdXRvXCIscC5zdHlsZS5jdXJzb3I9XCJhdXRvXCIsaS5hcHBlbmRDaGlsZChwKSxpfWZ1bmN0aW9uIGooZSl7Y29uc3Qgbz13aW5kb3cuaW5uZXJXaWR0aCx0PXdpbmRvdy5pbm5lckhlaWdodCxpPWEoXCJzd
Line truncated
|
||||
+93
-21
@@ -8,7 +8,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-dtGmAPWjcgykNC2GM60HzlzMwiAqHarVnnPf8NO0XrA='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-LqwtGSCiBtjq9Bnb5wA3kQFrFRgZWtDWcswiK656gEk='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
||||
|
||||
<link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
|
||||
@@ -203,6 +203,7 @@
|
||||
<div class="tools-section-items">
|
||||
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||
<a id="view-logs-page" aria-label="Dedicated logs page" href="/logs.html" target="_blank" rel="noopener" style="text-decoration:none;color:inherit">📄 Logs Page</a>
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||
@@ -245,9 +246,9 @@
|
||||
<path d="M10.5 7.5c3-1.5 6-1.5 9 0M10.5 16.5c3 1.5 6 1.5 9 0" stroke="white" stroke-width="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="name">Internet</span>
|
||||
<span class="name" data-i18n="card.internet">Internet</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="internet-pill" class="badge off">OFF</span>
|
||||
<span id="internet-pill" class="badge off" data-i18n-live-status="binary">OFF</span>
|
||||
</div>
|
||||
<div class="response-row">
|
||||
<span id="internet-time" class="response-time">--</span>
|
||||
@@ -267,15 +268,15 @@
|
||||
<line x1="12" y1="16.5" x2="12" y2="18" stroke="#0b0f1a" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="name">Auth</span>
|
||||
<span class="name" data-i18n="card.auth">Auth</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="auth-pill" class="badge off">NO</span>
|
||||
<span id="auth-pill" class="badge off" data-i18n-live-status="yesno">NO</span>
|
||||
</div>
|
||||
<div class="response-row">
|
||||
<span id="auth-status-text" class="response-time" style="font-size: 0.7rem;">Not configured</span>
|
||||
<span id="auth-status-text" class="response-time" style="font-size: 0.7rem;" data-i18n="card.auth.not_configured">Not configured</span>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="auth-settings-btn">Settings</button>
|
||||
<button id="auth-settings-btn" data-i18n="action.settings">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,7 +291,7 @@
|
||||
<path d="M12 13v4M9 19h6" stroke="#7D8FE3" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="name">Tailscale</span>
|
||||
<span class="name" data-i18n="card.tailscale">Tailscale</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="tailscale-pill" class="badge off">—</span>
|
||||
</div>
|
||||
@@ -308,9 +309,9 @@
|
||||
<div class="logo-wrap">
|
||||
<span style="font-size: 28px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">🔐</span>
|
||||
</div>
|
||||
<span class="name">DashCA</span>
|
||||
<span class="name" data-i18n="card.dashca">DashCA</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="badge-ca" class="badge off">OFF</span>
|
||||
<span id="badge-ca" class="badge off" data-i18n-live-status="binary">OFF</span>
|
||||
</div>
|
||||
<div class="response-row">
|
||||
<span id="time-ca" class="response-time">--</span>
|
||||
@@ -323,7 +324,7 @@
|
||||
<button class="creds-btn" id="creds-btn-ca" title="Auto-login credentials">🔑</button>
|
||||
<button class="options-btn" id="options-btn-ca" title="Edit service settings">⚙️</button>
|
||||
<button class="delete-btn" id="delete-btn-ca" title="Delete this service">🗑️</button>
|
||||
<button id="ca-open">Open</button>
|
||||
<button id="ca-open" data-i18n="action.open">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,16 +346,16 @@
|
||||
|
||||
<!-- Service Filter Bar -->
|
||||
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
|
||||
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
|
||||
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." data-i18n-placeholder="filter.services_placeholder" data-i18n-prefix="🔍 " style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
|
||||
<select id="service-filter-status" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Status</option>
|
||||
<option value="on">🟢 Online</option>
|
||||
<option value="off">🔴 Offline</option>
|
||||
<option value="all" data-i18n="filter.all_status">All Status</option>
|
||||
<option value="on" data-i18n="filter.online" data-i18n-prefix="🟢 ">🟢 Online</option>
|
||||
<option value="off" data-i18n="filter.offline" data-i18n-prefix="🔴 ">🔴 Offline</option>
|
||||
</select>
|
||||
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Categories</option>
|
||||
<option value="all" data-i18n="filter.all_categories">All Categories</option>
|
||||
</select>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;" data-i18n="filter.batch_operations" data-i18n-prefix="☰ ">☰ Batch Operations</button>
|
||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||
</div>
|
||||
|
||||
@@ -654,6 +655,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 +681,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 up to <strong>500 health check entries per service</strong> (plus container statistics and event logs).
|
||||
At high check frequencies this may consume significant disk space — on a host with many services the history file can grow to tens of megabytes.
|
||||
Adjust the <strong>health check interval</strong>, <strong>max entries per service</strong>, and <strong>health retention period</strong> in <strong>Disk Safety</strong> (the 💾 Disk button in the top bar) to control disk usage.
|
||||
</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 the <strong>💾 Disk</strong> button in the top bar (opens the Disk Safety modal)</li>
|
||||
<li>Set a <strong>health check polling interval</strong> (default: 30s)</li>
|
||||
<li>Set a <strong>health retention period</strong> (default: 30 days)</li>
|
||||
<li>Cap <strong>max health entries per service</strong> (default: 500)</li>
|
||||
<li>Cap <strong>max stats entries</strong> (default: 500)</li>
|
||||
<li>Click <strong>Clean Up Now</strong> to purge old data immediately</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>
|
||||
@@ -701,14 +761,23 @@
|
||||
var versionInfoClose = document.getElementById('version-info-close');
|
||||
var latestUpdateCheck = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function(character) {
|
||||
return {
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
}[character];
|
||||
});
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (value == null || value === '') return '—';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
if (typeof value === 'object') return escapeHtml(JSON.stringify(value));
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function renderInfoRow(label, value) {
|
||||
return '<div class="version-info-row"><span class="version-info-label">' + label + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
|
||||
return '<div class="version-info-row"><span class="version-info-label">' + escapeHtml(label) + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
|
||||
}
|
||||
|
||||
function renderHistory(history) {
|
||||
@@ -959,6 +1028,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>
|
||||
|
||||
+137
-18
@@ -1,17 +1,20 @@
|
||||
// ========== AUDIT LOG VIEWER ==========
|
||||
// DC-050: surface authenticated user identity (userEmail / userRole from
|
||||
// auditLogger.details), add outcome filter, pass confirm=CLEAR body for
|
||||
// destructive DELETE.
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('audit-modal', `<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
|
||||
<h3>📜 Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
Track all actions performed through the API.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
||||
<label class="text-muted-sm">Filter:</label>
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<label class="text-muted-sm">Category:</label>
|
||||
<select id="audit-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">All Actions</option>
|
||||
<option value="">All</option>
|
||||
<option value="service">Services</option>
|
||||
<option value="container">Containers</option>
|
||||
<option value="caddy">Caddy</option>
|
||||
@@ -20,6 +23,16 @@
|
||||
<option value="config">Config</option>
|
||||
<option value="auth">Auth</option>
|
||||
</select>
|
||||
<label class="text-muted-sm">Result:</label>
|
||||
<select id="audit-outcome-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">Any</option>
|
||||
<option value="success">✓ Success</option>
|
||||
<option value="failure">✗ Failure</option>
|
||||
</select>
|
||||
<label class="text-muted-sm" style="margin-left: 8px;">Since:</label>
|
||||
<input id="audit-since" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<label class="text-muted-sm">Until:</label>
|
||||
<input id="audit-until" type="datetime-local" style="padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<button id="audit-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="audit-clear-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
|
||||
@@ -45,26 +58,84 @@
|
||||
const refreshBtn = document.getElementById('audit-refresh-btn');
|
||||
const clearBtn = document.getElementById('audit-clear-btn');
|
||||
const filterSelect = document.getElementById('audit-filter');
|
||||
const outcomeSelect = document.getElementById('audit-outcome-filter');
|
||||
const sinceInput = document.getElementById('audit-since');
|
||||
const untilInput = document.getElementById('audit-until');
|
||||
const container = document.getElementById('audit-log-container');
|
||||
const loadMoreBtn = document.getElementById('audit-load-more');
|
||||
let currentOffset = 0;
|
||||
let inflight = null; // AbortController for the in-flight request
|
||||
let filterNonce = 0; // increments on every fresh (non-append) load; lets
|
||||
// an in-flight append detect the filter has changed
|
||||
// and skip its DOM splice.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// datetime-local fields carry no timezone offset — convert to ISO 8601
|
||||
// with the local offset so the server can compare correctly.
|
||||
function toIso(localDtValue) {
|
||||
if (!localDtValue) return null;
|
||||
// Browsers expose datetime-local as naive local time. new Date() on
|
||||
// that string parses it as LOCAL, so toISOString() yields the UTC
|
||||
// equivalent the server expects.
|
||||
const d = new Date(localDtValue);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function loadAudit(append) {
|
||||
try {
|
||||
if (!append) {
|
||||
// Cancel any pending request and bump the filter nonce so any
|
||||
// appending fetch (still in flight) knows to discard its response.
|
||||
if (inflight) inflight.abort();
|
||||
inflight = new AbortController();
|
||||
currentOffset = 0;
|
||||
filterNonce++;
|
||||
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||
} else {
|
||||
if (inflight) inflight.abort();
|
||||
inflight = new AbortController();
|
||||
}
|
||||
const myNonce = filterNonce;
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', String(currentOffset));
|
||||
const action = filterSelect.value;
|
||||
const outcome = outcomeSelect.value;
|
||||
const since = toIso(sinceInput.value);
|
||||
const until = toIso(untilInput.value);
|
||||
if (action) params.set('action', action);
|
||||
if (outcome) params.set('outcome', outcome);
|
||||
if (since) params.set('since', since);
|
||||
if (until) params.set('until', until);
|
||||
|
||||
const res = await fetch('/api/v1/audit-logs?' + params.toString(), {
|
||||
signal: inflight.signal,
|
||||
});
|
||||
// Surface 401/403/500 explicitly — the dashboard used to render any
|
||||
// non-success response as "no audit log entries yet," which is
|
||||
// misleading for an expired session.
|
||||
if (!res.ok) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${res.status}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const filter = filterSelect.value;
|
||||
let url = `/api/v1/audit-logs?limit=${PAGE_SIZE}&offset=${currentOffset}`;
|
||||
if (filter) url += `&action=${encodeURIComponent(filter)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const entries = data.success && data.entries ? data.entries : [];
|
||||
if (!data.success) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(data.error || 'unknown')}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// If a non-append load happened after this fetch was issued, the
|
||||
// operator changed filters; discard the now-stale response.
|
||||
if (!append && myNonce !== filterNonce) return;
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
|
||||
if (entries.length === 0 && !append) {
|
||||
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📜</span>No audit log entries yet. Actions will be logged automatically.</div>';
|
||||
const reason = data.filters && (data.filters.action || data.filters.outcome || data.filters.since || data.filters.until)
|
||||
? 'No entries match your filters.'
|
||||
: 'No audit log entries yet. Actions will be logged automatically.';
|
||||
container.innerHTML = `<div class="panel-empty"><span class="empty-icon">📜</span>${escapeHtml(reason)}</div>`;
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -72,20 +143,29 @@
|
||||
let html = '';
|
||||
if (!append) {
|
||||
html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">';
|
||||
html += '<th style="padding: 6px; text-align: left;">When</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Actor</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">IP</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Action</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Resource</th>';
|
||||
html += '<th style="padding: 6px; text-align: left;">Result</th>';
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const ok = e.outcome === 'success';
|
||||
const actor = actorLabel(e);
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(e.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp || '')}">${timeAgo(e.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; font-size: 0.78rem;">${actor}</td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(e.ip || '-')}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(e.action || '-')}</td>`;
|
||||
html += `<td style="padding: 6px;">${escapeHtml(e.resource || '-')}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'}</span></td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'} ${escapeHtml(e.outcome || '')}</span></td>`;
|
||||
html += '</tr>';
|
||||
if (e.details && Object.keys(e.details).length > 0) {
|
||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(e.details, null, 2))}</pre></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +173,14 @@
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
// Append rows to existing table
|
||||
const table = container.querySelector('table');
|
||||
if (table) table.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
currentOffset += entries.length;
|
||||
loadMoreBtn.style.display = entries.length >= PAGE_SIZE ? '' : 'none';
|
||||
// hasMore is reported by the server (post-filter total), so the
|
||||
// Load More button stays accurate when filters change mid-scroll.
|
||||
loadMoreBtn.style.display = data.hasMore ? '' : 'none';
|
||||
|
||||
// Toggle detail rows on click
|
||||
container.querySelectorAll('.audit-row').forEach(row => {
|
||||
@@ -113,10 +194,34 @@
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
// AbortError is expected when we deliberately cancel an in-flight
|
||||
// request (e.g. the operator changed filters mid-fetch) — don't
|
||||
// flash a "Failed: The user aborted a request" message over the
|
||||
// loading spinner. The new fetch has already kicked off.
|
||||
if (e && e.name === 'AbortError') return;
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the human-readable actor: prefer userEmail, fall back to
|
||||
// userId, fall back to bare IP. If no user attribution, mark as
|
||||
// "system" so the operator knows the entry came from an unauthenticated
|
||||
// or service path (e.g. cron-driven backups).
|
||||
function actorLabel(entry) {
|
||||
const d = entry.details || {};
|
||||
const email = d.userEmail;
|
||||
const id = d.userId;
|
||||
const role = d.userRole;
|
||||
const provider = d.viaProvider;
|
||||
if (email) {
|
||||
const tag = role ? ` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(role)}${provider ? '/' + escapeHtml(provider) : ''}]</span>` : '';
|
||||
return `${escapeHtml(email)}${tag}`;
|
||||
}
|
||||
if (id) return `<span style="font-family: monospace; color: var(--muted);">${escapeHtml(id)}</span>`;
|
||||
if (!entry.ip) return '<span style="color: var(--muted);">system</span>';
|
||||
return '<span style="color: var(--muted);">anon</span>';
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal?.classList.add('show');
|
||||
loadAudit(false);
|
||||
@@ -124,12 +229,26 @@
|
||||
wireModal(modal, cancelBtn);
|
||||
refreshBtn?.addEventListener('click', () => loadAudit(false));
|
||||
filterSelect?.addEventListener('change', () => loadAudit(false));
|
||||
outcomeSelect?.addEventListener('change', () => loadAudit(false));
|
||||
// Re-fetch on date change only when both fields have a value or both are
|
||||
// empty — typing one character shouldn't trigger a fetch for every keystroke.
|
||||
let dateDebounce;
|
||||
[sinceInput, untilInput].forEach((el) => {
|
||||
el?.addEventListener('change', () => {
|
||||
clearTimeout(dateDebounce);
|
||||
dateDebounce = setTimeout(() => loadAudit(false), 250);
|
||||
});
|
||||
});
|
||||
loadMoreBtn?.addEventListener('click', () => loadAudit(true));
|
||||
|
||||
clearBtn?.addEventListener('click', async () => {
|
||||
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/audit-logs', { method: 'DELETE' });
|
||||
const res = await secureFetch('/api/v1/audit-logs', {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'CLEAR' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) loadAudit(false);
|
||||
else showNotification('Error: ' + (data.error || 'Clear failed'), 'error');
|
||||
@@ -137,4 +256,4 @@
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
+37
-4
@@ -1,6 +1,12 @@
|
||||
// ========== GRID & STATUS HELPERS ==========
|
||||
(function () {
|
||||
|
||||
function statusLabel(up) {
|
||||
const i18n = window.DCI18n;
|
||||
if (!i18n || !i18n.isLoaded()) return up ? 'ON' : 'OFF';
|
||||
return i18n.t(up ? 'card.status.on' : 'card.status.off');
|
||||
}
|
||||
|
||||
/* Enhanced status helpers with response time tracking */
|
||||
function setQuick(id, up, responseTime = null) {
|
||||
const dot = document.getElementById(id + '-dot');
|
||||
@@ -14,7 +20,7 @@
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.textContent = up ? 'ON' : 'OFF';
|
||||
pill.textContent = statusLabel(up);
|
||||
pill.classList.toggle('on', up);
|
||||
pill.classList.toggle('off', !up);
|
||||
}
|
||||
@@ -172,7 +178,10 @@
|
||||
|
||||
row.appendChild(el('span', 'spacer'));
|
||||
|
||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||
const pill = el('span', 'badge off', 'OFF');
|
||||
pill.id = 'badge-' + s.id;
|
||||
pill.setAttribute('data-i18n-live-status', 'binary');
|
||||
row.appendChild(pill);
|
||||
|
||||
// Update available badge (hidden by default, shown when update detected)
|
||||
const updateBadge = el('span', 'update-available-badge', 'UPDATE');
|
||||
@@ -301,6 +310,7 @@
|
||||
}
|
||||
|
||||
const btn = el('button', null, 'Open');
|
||||
btn.setAttribute('data-i18n', 'action.open');
|
||||
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
|
||||
btnRow.appendChild(btn);
|
||||
card.appendChild(btnRow);
|
||||
@@ -309,6 +319,12 @@
|
||||
root.appendChild(card);
|
||||
}
|
||||
|
||||
// Cards can be created after the initial language pass. Translate their
|
||||
// initial live state and controls immediately instead of waiting for poll.
|
||||
if (window.DCI18n && window.DCI18n.isLoaded()) {
|
||||
window.DCI18n.applyTranslations();
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
root.querySelectorAll('.card').forEach(card => card.classList.add('loaded'));
|
||||
});
|
||||
@@ -332,7 +348,7 @@
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.textContent = up ? 'ON' : 'OFF';
|
||||
pill.textContent = statusLabel(up);
|
||||
pill.classList.toggle('on', up);
|
||||
pill.classList.toggle('off', !up);
|
||||
}
|
||||
@@ -349,6 +365,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 +420,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 +449,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)
|
||||
|
||||
@@ -47,10 +47,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:rgba(243,156,18,0.08);border:1px solid rgba(243,156,18,0.25);border-radius:8px;padding:12px;margin-bottom:16px;">
|
||||
<div style="font-size:0.82rem;color:#f0a040;line-height:1.45;">
|
||||
⚠ <strong>Disk impact:</strong> Lowering the health check interval increases how often data is written to disk.
|
||||
DashCaddy caps history at <strong>max entries per service</strong> and prunes entries older than the retention period,
|
||||
so these two values together determine steady-state disk usage. For busy hosts, prefer a longer interval (60–120s)
|
||||
and a lower entry cap.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:16px;">
|
||||
${settingRow('Health Check Interval', 'healthInterval', c.healthCheckInterval, 'seconds', Math.round((c.healthCheckInterval||30000)/1000), 5, 300)}
|
||||
${settingRow('Max Health Entries/Service', 'healthMaxEntries', c.healthMaxEntries, 'entries', c.healthMaxEntries||500, 50, 5000)}
|
||||
${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||14, 1, 90)}
|
||||
${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||30, 1, 90)}
|
||||
${settingRow('Max Stats Entries', 'statsMaxEntries', c.statsMaxEntries, 'entries', c.statsMaxEntries||500, 100, 10000)}
|
||||
${settingRow('Max Audit Entries', 'auditMaxEntries', c.auditMaxEntries, 'entries', c.auditMaxEntries||1000, 100, 10000)}
|
||||
</div>
|
||||
|
||||
+85
-13
@@ -1,40 +1,110 @@
|
||||
// ========== ERROR LOG VIEWER ==========
|
||||
// DC-051: The /api/v1/error-logs route now parses the unified-logger
|
||||
// ── (U+2500) separator (verified 2026-08-18 — previous '=' splitter
|
||||
// returned ZERO entries and the modal always rendered "No errors logged").
|
||||
// The modal now renders the captured `details` (stack trace + req context)
|
||||
// and offers a Level filter + tail cap mirroring the audit-log viewer
|
||||
// (DC-050). Mirrors the audit-log-viewer shape (5f95fdc).
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('error-log-modal', '<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>📋 Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button><button id="error-log-close" class="close-btn">✕</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');
|
||||
const MAX_LEVELS = ['ERR', 'WRN', 'INF', 'DBG'];
|
||||
|
||||
injectModal('error-log-modal', [
|
||||
'<div id="error-log-modal" class="logs-modal">',
|
||||
' <div class="logs-modal-content">',
|
||||
' <div class="logs-header">',
|
||||
' <h3>📋 Error Logs</h3>',
|
||||
' <div class="logs-controls">',
|
||||
' <select id="error-log-level" aria-label="Filter by level" style="padding:4px 8px!important;font-size:.85rem!important">',
|
||||
' <option value="">All levels</option>',
|
||||
' <option value="ERR">Errors</option>',
|
||||
' <option value="WRN">Warnings</option>',
|
||||
' <option value="INF">Info</option>',
|
||||
' <option value="DBG">Debug</option>',
|
||||
' </select>',
|
||||
' <select id="error-log-tail" aria-label="Tail length" style="padding:4px 8px!important;font-size:.85rem!important">',
|
||||
' <option value="50">Last 50</option>',
|
||||
' <option value="100" selected>Last 100</option>',
|
||||
' <option value="200">Last 200</option>',
|
||||
' <option value="500">Last 500</option>',
|
||||
' </select>',
|
||||
' <button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button>',
|
||||
' <button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button>',
|
||||
' <button id="error-log-close" class="close-btn">✕</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="logs-container">',
|
||||
' <div id="error-log-meta" class="logs-meta" style="padding:6px 12px;color:var(--muted);font-size:.8rem;border-bottom:1px solid var(--border)"></div>',
|
||||
' <div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
'</div>',
|
||||
].join(''));
|
||||
|
||||
const modal = document.getElementById('error-log-modal');
|
||||
const content = document.getElementById('error-log-content');
|
||||
const meta = document.getElementById('error-log-meta');
|
||||
const viewBtn = document.getElementById('view-error-logs');
|
||||
const refreshBtn = document.getElementById('error-log-refresh');
|
||||
const clearBtn = document.getElementById('error-log-clear');
|
||||
const closeBtn = document.getElementById('error-log-close');
|
||||
const levelSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-level'));
|
||||
const tailSelect = /** @type {HTMLSelectElement|null} */ (document.getElementById('error-log-tail'));
|
||||
|
||||
function levelClass(level) {
|
||||
const L = (level || '').toUpperCase();
|
||||
if (L === 'ERR') return 'log-entry error';
|
||||
if (L === 'WRN') return 'log-entry warn';
|
||||
if (L === 'INF') return 'log-entry info';
|
||||
if (L === 'DBG') return 'log-entry debug';
|
||||
return 'log-entry';
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MiB';
|
||||
}
|
||||
|
||||
async function loadErrorLogs() {
|
||||
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
|
||||
|
||||
meta.textContent = '';
|
||||
|
||||
const tail = encodeURIComponent(tailSelect.value || '100');
|
||||
const level = levelSelect.value || '';
|
||||
const qs = `tail=${tail}` + (level ? `&level=${encodeURIComponent(level)}` : '');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/error-logs');
|
||||
const response = await fetch('/api/v1/error-logs?' + qs);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.logs) {
|
||||
if (data.logs.length === 0) {
|
||||
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">✅ No errors logged! Everything is working smoothly.</div>';
|
||||
} else {
|
||||
content.innerHTML = data.logs.map(log => {
|
||||
content.innerHTML = data.logs.map((log, idx) => {
|
||||
const date = new Date(log.timestamp).toLocaleString();
|
||||
const lvl = (log.level || 'ERR').toUpperCase();
|
||||
const detailsId = `error-log-details-${idx}`;
|
||||
const details = log.details ? escapeHtml(log.details) : null;
|
||||
const ctx = log.context ? `<strong>${escapeHtml(log.context)}</strong>: ` : '';
|
||||
const msg = escapeHtml(log.message || '');
|
||||
return `
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${date}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
<div class="${levelClass(log.level)}">
|
||||
<span class="log-timestamp">${escapeHtml(date)}</span>
|
||||
<span class="log-level">${escapeHtml(lvl)}</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(log.context)}</strong>: ${escapeHtml(log.error)}
|
||||
${log.details ? `<br><small style="opacity: 0.7;">${escapeHtml(log.details)}</small>` : ''}
|
||||
${ctx}${msg}
|
||||
${details ? `<br><details id="${detailsId}"><summary style="cursor:pointer;opacity:.7">stack + request context</summary><pre style="margin:6px 0 0;font-size:.75rem;background:var(--card-bg);padding:8px;border-radius:4px;overflow-x:auto">${details}</pre></details>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
const sizeStr = formatBytes(data.totalSize);
|
||||
const truncStr = data.truncated ? ' (showing last 2 MiB)' : '';
|
||||
const returnedStr = `${data.returned ?? data.logs.length}`;
|
||||
meta.textContent = `${returnedStr} entries · ${sizeStr}${truncStr}`;
|
||||
} else {
|
||||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||||
}
|
||||
@@ -49,7 +119,7 @@
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showNotification('✅ Error logs cleared', 'success', 3000);
|
||||
loadErrorLogs();
|
||||
@@ -68,5 +138,7 @@
|
||||
|
||||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||||
clearBtn?.addEventListener('click', clearErrorLogs);
|
||||
levelSelect?.addEventListener('change', loadErrorLogs);
|
||||
tailSelect?.addEventListener('change', loadErrorLogs);
|
||||
wireModal(modal, closeBtn);
|
||||
})();
|
||||
})();
|
||||
+26
-5
@@ -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
|
||||
@@ -129,18 +147,21 @@ function renderDnsCards() {
|
||||
`<span id="${safeId}-dot" class="dot bad at-bl"></span>`
|
||||
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
|
||||
+ `<span class="name">${label}</span><span class="spacer"></span>`
|
||||
+ `<span id="${safeId}-pill" class="badge off">OFF</span></div>`
|
||||
+ `<span id="${safeId}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div>`
|
||||
+ `<div class="response-row"><span id="${safeId}-time" class="response-time">--</span></div>`
|
||||
+ `<div class="health-row" id="health-${safeId}"><span id="uptime-${safeId}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${safeId}" style="width: 0%"></div></div></div>`
|
||||
+ `<div class="btn-row">`
|
||||
+ `<button id="${safeId}-restart" class="restart-btn">Restart</button>`
|
||||
+ `<button id="${safeId}-restart" class="restart-btn" data-i18n="action.restart">Restart</button>`
|
||||
+ `<button id="${safeId}-update" class="update-btn" title="Update DNS server">⬆️</button>`
|
||||
+ `<button id="${safeId}-open">Open</button>`
|
||||
+ `<button id="${safeId}-logs" class="logs-btn">Logs</button>`
|
||||
+ `<button id="${safeId}-open" data-i18n="action.open">Open</button>`
|
||||
+ `<button id="${safeId}-logs" class="logs-btn" data-i18n="action.logs">Logs</button>`
|
||||
+ `<button id="${safeId}-settings" class="settings-btn">⚙️</button>`
|
||||
+ `</div>`;
|
||||
topRow.insertBefore(card, firstChild);
|
||||
});
|
||||
if (window.DCI18n && window.DCI18n.isLoaded()) {
|
||||
window.DCI18n.applyTranslations();
|
||||
}
|
||||
}
|
||||
window.renderDnsCards = renderDnsCards;
|
||||
|
||||
|
||||
@@ -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,260 @@
|
||||
/**
|
||||
* 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;
|
||||
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) {
|
||||
// Translation keys are semantic identifiers, not English fallback copy.
|
||||
// Callers that render before loading should retain their existing DOM text.
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
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 a successfully loaded dictionary. On HTTP/network failure,
|
||||
// retain the existing readable DOM copy instead of exposing semantic keys.
|
||||
if (reqId === _langRequestId && loaded) 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');
|
||||
const prefix = el.getAttribute('data-i18n-prefix') || '';
|
||||
el.textContent = prefix + t(key);
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
const prefix = el.getAttribute('data-i18n-prefix') || '';
|
||||
el.placeholder = prefix + t(key);
|
||||
});
|
||||
// Live status pills derive the translation key from current runtime state.
|
||||
// A language switch must never reset an online card to its initial OFF copy.
|
||||
document.querySelectorAll('[data-i18n-live-status]').forEach(el => {
|
||||
const card = el.closest('[data-status]');
|
||||
const isOn = card && card.getAttribute('data-status') === 'on';
|
||||
const mode = el.getAttribute('data-i18n-live-status');
|
||||
const key = mode === 'yesno'
|
||||
? (isOn ? 'card.status.yes' : 'card.status.no')
|
||||
: (isOn ? 'card.status.on' : 'card.status.off');
|
||||
el.textContent = 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();
|
||||
const reqId = ++_langRequestId;
|
||||
loadTranslations(currentLang, reqId).then(() => {
|
||||
if (reqId === _langRequestId && loaded) applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, isLoaded, 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,46 @@
|
||||
// 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 (_) {} }
|
||||
}
|
||||
|
||||
// Called by totp-auth.js after a successful mid-session re-auth:
|
||||
// clears the latch so connect() can proceed again and resets the
|
||||
// failure backoff. (Plain _sseReconnect/connect() would early-return
|
||||
// on the latch forever — the user would need a manual F5.)
|
||||
function resumeAfterReauth() {
|
||||
_sseManuallyClosed = false;
|
||||
_sseFailCount = 0;
|
||||
reconnectDelay = 1000;
|
||||
connect();
|
||||
}
|
||||
|
||||
// Start on page load
|
||||
connect();
|
||||
|
||||
// Expose for debugging
|
||||
// Expose for debugging and cross-module coordination
|
||||
window._sseReconnect = connect;
|
||||
window._sseClose = closeAndStop;
|
||||
window._sseResume = resumeAfterReauth;
|
||||
})();
|
||||
@@ -0,0 +1,172 @@
|
||||
// Logs page (status/logs.html) — dedicated admin log viewer.
|
||||
// Two tabs: Error log (calls /api/v1/error-logs) + Container (calls
|
||||
// /api/v1/logs/containers + /api/v1/logs/container/:id). Mirrors the
|
||||
// /api/v1/error-logs parser fix from DC-051 — U+2500 separator, capped
|
||||
// tail, level filter.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MiB';
|
||||
}
|
||||
|
||||
const out = document.getElementById('output');
|
||||
const meta = document.getElementById('meta');
|
||||
const tabError = document.getElementById('tab-error');
|
||||
const tabContainer = document.getElementById('tab-container');
|
||||
const errorCtrls = document.getElementById('error-controls');
|
||||
const containerCtrls = document.getElementById('container-controls');
|
||||
|
||||
let activeTab = 'error';
|
||||
let containers = [];
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab = tab;
|
||||
tabError.classList.toggle('active', tab === 'error');
|
||||
tabContainer.classList.toggle('active', tab === 'container');
|
||||
errorCtrls.style.display = tab === 'error' ? 'flex' : 'none';
|
||||
containerCtrls.style.display = tab === 'container' ? 'flex' : 'none';
|
||||
if (tab === 'error') loadErrorLog();
|
||||
else loadContainerList();
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || (data && data.success === false)) {
|
||||
throw new Error((data && (data.error || data.message)) || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadErrorLog() {
|
||||
const tailEl = /** @type {HTMLSelectElement} */ (document.getElementById('tail'));
|
||||
const levelEl = /** @type {HTMLSelectElement} */ (document.getElementById('level'));
|
||||
const tail = tailEl.value;
|
||||
const level = levelEl.value;
|
||||
let qs = `tail=${encodeURIComponent(tail)}`;
|
||||
if (level) qs += '&level=' + encodeURIComponent(level);
|
||||
|
||||
out.innerHTML = '<div class="logs-loading">Loading error log…</div>';
|
||||
meta.textContent = '';
|
||||
try {
|
||||
const data = await fetchJson('/api/v1/error-logs?' + qs);
|
||||
const logs = data.logs || [];
|
||||
if (logs.length === 0) {
|
||||
out.innerHTML = '<div class="empty">✅ No errors logged</div>';
|
||||
} else {
|
||||
out.innerHTML = logs.map((log, idx) => {
|
||||
const date = new Date(log.timestamp).toLocaleString();
|
||||
const lvl = (log.level || 'ERR').toUpperCase();
|
||||
const cls = ['ERR','WRN','INF','DBG'].includes(lvl) ? lvl.toLowerCase() : 'error';
|
||||
const details = log.details ? escapeHtml(log.details) : null;
|
||||
return `
|
||||
<div class="log-entry ${cls}">
|
||||
<span class="log-timestamp">${escapeHtml(date)}</span>
|
||||
<span class="log-level">${escapeHtml(lvl)}</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(log.context || '')}</strong>: ${escapeHtml(log.message || '')}
|
||||
${details ? `<details><summary style="cursor:pointer;opacity:.7">stack + request context</summary><pre>${details}</pre></details>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
const sizeStr = formatBytes(data.totalSize);
|
||||
const truncStr = data.truncated ? ' (last 2 MiB)' : '';
|
||||
const returnedStr = data.returned ?? logs.length;
|
||||
meta.textContent = `${returnedStr} entries · ${sizeStr}${truncStr}`;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearErrorLog() {
|
||||
if (!confirm('Clear all error logs?')) return;
|
||||
try {
|
||||
await fetchJson('/api/v1/error-logs', { method: 'DELETE' });
|
||||
meta.textContent = '✅ cleared';
|
||||
loadErrorLog();
|
||||
} catch (err) {
|
||||
meta.textContent = '❌ ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContainerList() {
|
||||
const sel = document.getElementById('container-select');
|
||||
out.innerHTML = '<div class="logs-loading">Loading containers…</div>';
|
||||
document.getElementById('container-meta').textContent = '';
|
||||
try {
|
||||
const data = await fetchJson('/api/v1/logs/containers');
|
||||
containers = data.containers || [];
|
||||
sel.innerHTML = containers.map(c => {
|
||||
const name = c.name || c.id;
|
||||
const state = (c.status || 'unknown');
|
||||
return `<option value="${escapeHtml(c.id)}">${escapeHtml(name)} (${escapeHtml(state)})</option>`;
|
||||
}).join('');
|
||||
if (containers.length === 0) {
|
||||
out.innerHTML = '<div class="empty">No containers running</div>';
|
||||
return;
|
||||
}
|
||||
loadContainerLog();
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContainerLog() {
|
||||
const sel = /** @type {HTMLSelectElement} */ (document.getElementById('container-select'));
|
||||
const tailEl = /** @type {HTMLSelectElement} */ (document.getElementById('container-tail'));
|
||||
const tail = tailEl.value;
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
out.innerHTML = '<div class="empty">Select a container</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="logs-loading">Loading container logs…</div>';
|
||||
document.getElementById('container-meta').textContent = '';
|
||||
try {
|
||||
const data = await fetchJson(`/api/v1/logs/container/${encodeURIComponent(id)}?tail=${encodeURIComponent(tail)}×tamps=true`);
|
||||
const logs = data.logs || [];
|
||||
if (logs.length === 0) {
|
||||
out.innerHTML = '<div class="empty">No log lines</div>';
|
||||
} else {
|
||||
out.innerHTML = logs.map(l => {
|
||||
const cls = l.stream === 'stderr' ? 'error' : 'info';
|
||||
const ts = l.timestamp || (data.logs.length ? '' : '');
|
||||
return `
|
||||
<div class="log-entry ${cls}">
|
||||
${ts ? `<span class="log-timestamp">${escapeHtml(new Date(ts).toLocaleString())}</span>` : ''}
|
||||
<span class="log-level">${l.stream === 'stderr' ? 'ERR' : 'OUT'}</span>
|
||||
<div class="log-message"><pre style="margin:0;white-space:pre-wrap">${escapeHtml(l.text)}</pre></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
const containerName = data.containerName || '';
|
||||
document.getElementById('container-meta').textContent = `${escapeHtml(containerName)} · ${logs.length} lines`;
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="empty">❌ ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
tabError.addEventListener('click', () => switchTab('error'));
|
||||
tabContainer.addEventListener('click', () => switchTab('container'));
|
||||
document.getElementById('refresh').addEventListener('click', loadErrorLog);
|
||||
document.getElementById('clear').addEventListener('click', clearErrorLog);
|
||||
document.getElementById('level').addEventListener('change', loadErrorLog);
|
||||
document.getElementById('tail').addEventListener('change', loadErrorLog);
|
||||
document.getElementById('container-refresh').addEventListener('click', loadContainerLog);
|
||||
document.getElementById('container-select').addEventListener('change', loadContainerLog);
|
||||
document.getElementById('container-tail').addEventListener('change', loadContainerLog);
|
||||
|
||||
switchTab('error');
|
||||
})();
|
||||
@@ -377,8 +377,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();
|
||||
|
||||
@@ -125,6 +125,15 @@
|
||||
if (typeof window.initializeDashboard === 'function') {
|
||||
window.initializeDashboard();
|
||||
}
|
||||
// Resume live updates after mid-session re-auth. The auth-loss
|
||||
// handlers latched polling + SSE off when the session expired
|
||||
// (grid.js sets _dcAuthLost, live-events.js latches the stream
|
||||
// closed); a fresh login must clear both and reconnect, or the
|
||||
// dashboard stays frozen on stale data until a manual F5.
|
||||
window._dcAuthLost = false;
|
||||
if (typeof window._sseResume === 'function') window._sseResume();
|
||||
else if (typeof window._sseReconnect === 'function') window._sseReconnect();
|
||||
if (typeof window.refreshAll === 'function') window.refreshAll();
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Invalid code';
|
||||
errorEl.className = 'totp-error';
|
||||
|
||||
@@ -169,6 +169,11 @@
|
||||
'4h': '4 hours', '8h': '8 hours', '12h': '12 hours', '24h': '24 hours', 'never': 'Disabled'
|
||||
};
|
||||
|
||||
function translated(key, fallback) {
|
||||
const i18n = window.DCI18n;
|
||||
return i18n && i18n.isLoaded() ? i18n.t(key) : fallback;
|
||||
}
|
||||
|
||||
function updateAuthCard(active, duration) {
|
||||
const card = document.getElementById('auth-card');
|
||||
const pill = document.getElementById('auth-pill');
|
||||
@@ -179,15 +184,15 @@
|
||||
if (active) {
|
||||
card.setAttribute('data-status', 'on');
|
||||
pill.className = 'badge on';
|
||||
pill.textContent = 'YES';
|
||||
pill.textContent = translated('card.status.yes', 'YES');
|
||||
dot.className = 'dot ok at-bl';
|
||||
statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration);
|
||||
} else {
|
||||
card.setAttribute('data-status', 'off');
|
||||
pill.className = 'badge off';
|
||||
pill.textContent = 'NO';
|
||||
pill.textContent = translated('card.status.no', 'NO');
|
||||
dot.className = 'dot bad at-bl';
|
||||
statusText.textContent = 'Not configured';
|
||||
statusText.textContent = translated('card.auth.not_configured', 'Not configured');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>DashCaddy — Logs</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
body.logs-page { padding: 0; margin: 0; background: var(--bg, #0e1116); color: var(--fg, #e6e6e6); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.logs-wrap { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
|
||||
.logs-top { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--card-bg, #161a22); border-radius: 8px; margin-bottom: 16px; }
|
||||
.logs-top h1 { margin: 0; font-size: 1.1rem; }
|
||||
.logs-tabs { display: flex; gap: 6px; margin-left: auto; }
|
||||
.logs-tabs button { padding: 6px 12px; background: transparent; border: 1px solid var(--border, #2a2f3a); color: inherit; border-radius: 6px; cursor: pointer; font: inherit; font-size: .85rem; }
|
||||
.logs-tabs button.active { background: var(--accent, #4f8cff); color: white; border-color: transparent; }
|
||||
.logs-controls { display: flex; gap: 8px; align-items: center; padding: 10px 16px; background: var(--card-bg, #161a22); border-radius: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.logs-controls select, .logs-controls input { background: var(--bg, #0e1116); color: inherit; border: 1px solid var(--border, #2a2f3a); padding: 4px 8px; border-radius: 4px; font: inherit; font-size: .85rem; }
|
||||
.logs-controls button { padding: 6px 12px; background: var(--accent, #4f8cff); color: white; border: none; border-radius: 4px; cursor: pointer; font: inherit; font-size: .85rem; }
|
||||
.logs-controls button.danger { background: color-mix(in srgb, #ff5555 25%, transparent); border: 1px solid #ff5555; color: #ff5555; }
|
||||
.logs-meta { color: var(--muted, #8a93a6); font-size: .8rem; margin-left: auto; }
|
||||
.logs-output { background: var(--card-bg, #161a22); border-radius: 8px; padding: 12px; max-height: 70vh; overflow-y: auto; font-size: .85rem; line-height: 1.4; }
|
||||
.logs-output .log-entry { padding: 8px 10px; border-bottom: 1px solid var(--border, #2a2f3a); }
|
||||
.logs-output .log-entry:last-child { border-bottom: none; }
|
||||
.logs-output .log-entry.error { border-left: 3px solid #ff5555; }
|
||||
.logs-output .log-entry.warn { border-left: 3px solid #f0b400; }
|
||||
.logs-output .log-entry.info { border-left: 3px solid #4f8cff; }
|
||||
.logs-output .log-entry.debug { border-left: 3px solid #8a93a6; }
|
||||
.logs-output .log-timestamp { color: var(--muted, #8a93a6); margin-right: 8px; font-size: .75rem; }
|
||||
.logs-output .log-level { display: inline-block; padding: 0 6px; border-radius: 3px; font-size: .7rem; font-weight: 600; margin-right: 8px; min-width: 38px; text-align: center; }
|
||||
.log-entry.error .log-level { background: #ff5555; color: white; }
|
||||
.log-entry.warn .log-level { background: #f0b400; color: black; }
|
||||
.log-entry.info .log-level { background: #4f8cff; color: white; }
|
||||
.log-entry.debug .log-level { background: #555; color: white; }
|
||||
.logs-output .log-message pre { margin: 6px 0 0; font-size: .75rem; padding: 6px 8px; background: rgba(0,0,0,0.25); border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
||||
.logs-output .empty { color: var(--muted, #8a93a6); text-align: center; padding: 40px; }
|
||||
.logs-loading { color: var(--muted, #8a93a6); text-align: center; padding: 40px; }
|
||||
.logs-back { padding: 4px 10px; background: transparent; border: 1px solid var(--border, #2a2f3a); color: inherit; border-radius: 4px; cursor: pointer; font: inherit; font-size: .85rem; text-decoration: none; }
|
||||
.container-pick { display: flex; gap: 6px; align-items: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="logs-page">
|
||||
<div class="logs-wrap">
|
||||
<div class="logs-top">
|
||||
<a href="/" class="logs-back">← Back</a>
|
||||
<h1>📋 Logs</h1>
|
||||
<div class="logs-tabs">
|
||||
<button id="tab-error" class="active">Error log</button>
|
||||
<button id="tab-container">Container</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error log controls -->
|
||||
<div id="error-controls" class="logs-controls">
|
||||
<label>Level:
|
||||
<select id="level">
|
||||
<option value="">All</option>
|
||||
<option value="ERR">Errors</option>
|
||||
<option value="WRN">Warnings</option>
|
||||
<option value="INF">Info</option>
|
||||
<option value="DBG">Debug</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Tail:
|
||||
<select id="tail">
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="refresh">🔄 Refresh</button>
|
||||
<button id="clear" class="danger">🗑️ Clear</button>
|
||||
<span class="logs-meta" id="meta"></span>
|
||||
</div>
|
||||
|
||||
<!-- Container log controls -->
|
||||
<div id="container-controls" class="logs-controls" style="display:none">
|
||||
<label>Container:
|
||||
<select id="container-select"></select>
|
||||
</label>
|
||||
<label>Tail:
|
||||
<select id="container-tail">
|
||||
<option value="50">50</option>
|
||||
<option value="200" selected>200</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="container-refresh">🔄 Refresh</button>
|
||||
<span class="logs-meta" id="container-meta"></span>
|
||||
</div>
|
||||
|
||||
<div class="logs-output" id="output">
|
||||
<div class="logs-loading">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/logs-page.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
||||
const CACHE = 'dashcaddy-shell-78eab743c2';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const statusRoot = path.join(__dirname, '..');
|
||||
|
||||
function makeElement(attrs = {}, text = '', card = null) {
|
||||
return {
|
||||
attrs: { ...attrs }, textContent: text, placeholder: '', title: '',
|
||||
getAttribute(name) { return this.attrs[name] || null; },
|
||||
closest(selector) { return selector === '[data-status]' ? card : null; },
|
||||
};
|
||||
}
|
||||
|
||||
function loadI18n(fetchImpl, elements = {}) {
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'i18n.js'), 'utf8');
|
||||
const listeners = {};
|
||||
const document = {
|
||||
readyState: 'loading', documentElement: {},
|
||||
addEventListener(name, fn) { listeners[name] = fn; },
|
||||
querySelectorAll(selector) { return elements[selector] || []; },
|
||||
querySelector() { return null; }, getElementById() { return null; },
|
||||
createElement() { return { style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} }; },
|
||||
};
|
||||
const context = {
|
||||
window: {}, document, console, fetch: fetchImpl,
|
||||
localStorage: { getItem() { return null; }, setItem() {} },
|
||||
setTimeout, clearTimeout,
|
||||
};
|
||||
vm.runInNewContext(source, context, { filename: 'i18n.js' });
|
||||
return { api: context.window.DCI18n, listeners };
|
||||
}
|
||||
|
||||
test('English dictionary is fetched and semantic keys never replace English card copy', async () => {
|
||||
const card = makeElement({ 'data-i18n': 'card.internet' }, 'Internet');
|
||||
const calls = [];
|
||||
const { api } = loadI18n(async url => {
|
||||
calls.push(url);
|
||||
return { ok: true, async json() { return { translations: { 'card.internet': 'Internet' } }; } };
|
||||
}, {
|
||||
'[data-i18n]': [card], '[data-i18n-placeholder]': [],
|
||||
'[data-i18n-live-status]': [], '[data-i18n-title]': [],
|
||||
});
|
||||
await api.loadTranslations('en');
|
||||
api.applyTranslations();
|
||||
assert.deepEqual(calls, ['/api/v1/i18n/translations/en']);
|
||||
assert.equal(card.textContent, 'Internet');
|
||||
assert.equal(api.isLoaded(), true);
|
||||
});
|
||||
|
||||
|
||||
test('failed language switch preserves existing readable labels', async () => {
|
||||
const label = makeElement({ 'data-i18n': 'card.internet' }, 'Internet');
|
||||
const elements = {
|
||||
'[data-i18n]': [label], '[data-i18n-placeholder]': [],
|
||||
'[data-i18n-live-status]': [], '[data-i18n-title]': [],
|
||||
};
|
||||
const { api } = loadI18n(async () => ({ ok: false, async json() { return {}; } }), elements);
|
||||
api.setLanguage('es');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(api.isLoaded(), false);
|
||||
assert.equal(label.textContent, 'Internet');
|
||||
assert.notEqual(label.textContent, 'card.internet');
|
||||
});
|
||||
|
||||
test('language switch preserves ON runtime state and translates a dynamic DNS pill immediately', async () => {
|
||||
const onlineCard = { getAttribute(name) { return name === 'data-status' ? 'on' : null; } };
|
||||
const staticPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard);
|
||||
const dynamicDnsPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard);
|
||||
const elements = {
|
||||
'[data-i18n]': [], '[data-i18n-placeholder]': [],
|
||||
'[data-i18n-live-status]': [staticPill, dynamicDnsPill], '[data-i18n-title]': [],
|
||||
};
|
||||
const { api } = loadI18n(async () => ({
|
||||
ok: true,
|
||||
async json() { return { translations: { 'card.status.on': 'ENC', 'card.status.off': 'APAG' } }; },
|
||||
}), elements);
|
||||
await api.loadTranslations('es');
|
||||
api.applyTranslations();
|
||||
assert.equal(staticPill.textContent, 'ENC');
|
||||
assert.equal(dynamicDnsPill.textContent, 'ENC');
|
||||
assert.notEqual(staticPill.textContent, 'APAG', 'online state must not reset to OFF');
|
||||
});
|
||||
|
||||
test('dynamic card template marks its pill and reapplies translations after insertion', () => {
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'globals.js'), 'utf8');
|
||||
assert.match(source, /data-i18n-live-status="binary"/);
|
||||
assert.match(source, /DCI18n\.isLoaded\(\)/);
|
||||
assert.match(source, /DCI18n\.applyTranslations\(\)/);
|
||||
});
|
||||
|
||||
|
||||
test('ordinary service card built after i18n load is translated immediately', () => {
|
||||
class Node {
|
||||
constructor(tag = 'div') {
|
||||
this.tag = tag; this.children = []; this.attrs = {}; this.textContent = '';
|
||||
this.className = ''; this.id = ''; this.style = {};
|
||||
this.classList = { add() {}, toggle() {} };
|
||||
}
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||
setAttribute(name, value) { this.attrs[name] = String(value); }
|
||||
getAttribute(name) { return this.attrs[name] || null; }
|
||||
closest(selector) {
|
||||
if (selector === '[data-status]' && this.attrs['data-status']) return this;
|
||||
return this.parentNode ? this.parentNode.closest(selector) : null;
|
||||
}
|
||||
addEventListener() {}
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const visit = node => {
|
||||
const attr = selector.match(/^\[([^\]]+)\]$/);
|
||||
if (attr && Object.prototype.hasOwnProperty.call(node.attrs, attr[1])) found.push(node);
|
||||
if (selector === '.card' && node.className.split(/\s+/).includes('card')) found.push(node);
|
||||
node.children.forEach(visit);
|
||||
};
|
||||
this.children.forEach(visit);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
const cards = new Node('section');
|
||||
const document = {
|
||||
createElement(tag) { return new Node(tag); },
|
||||
getElementById(id) { return id === 'cards' ? cards : null; },
|
||||
querySelector() { return null; },
|
||||
};
|
||||
const translations = { 'card.status.off': 'APAG', 'action.open': 'Abrir' };
|
||||
const window = {
|
||||
APPS: [{ id: 'demo', name: 'Demo', logo: '/demo.png' }],
|
||||
DCI18n: {
|
||||
isLoaded() { return true; },
|
||||
t(key) { return translations[key] || key; },
|
||||
applyTranslations() {
|
||||
cards.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = this.t(el.getAttribute('data-i18n'));
|
||||
});
|
||||
cards.querySelectorAll('[data-i18n-live-status]').forEach(el => {
|
||||
const card = el.closest('[data-status]');
|
||||
const key = card.getAttribute('data-status') === 'on' ? 'card.status.on' : 'card.status.off';
|
||||
el.textContent = this.t(key);
|
||||
});
|
||||
},
|
||||
},
|
||||
open() {},
|
||||
};
|
||||
const context = {
|
||||
window, document, console, SITE: { dnsServers: {} },
|
||||
buildServiceUrl(id) { return 'https://' + id + '.sami'; },
|
||||
requestAnimationFrame(fn) { fn(); }, fetch: async () => ({ ok: true }),
|
||||
setTimeout, clearTimeout, performance: { now() { return 0; } },
|
||||
};
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8');
|
||||
vm.runInNewContext(source, context, { filename: 'grid.js' });
|
||||
// grid.js initializes APPS itself; emulate service loading after module init.
|
||||
window.APPS = [{ id: 'demo', name: 'Demo', logo: '/demo.png' }];
|
||||
window.buildGrid();
|
||||
const livePills = cards.querySelectorAll('[data-i18n-live-status]');
|
||||
const openButtons = cards.querySelectorAll('[data-i18n]');
|
||||
assert.equal(livePills.length, 1);
|
||||
assert.equal(livePills[0].textContent, 'APAG');
|
||||
assert.equal(openButtons.length, 1);
|
||||
assert.equal(openButtons[0].textContent, 'Abrir');
|
||||
});
|
||||
|
||||
test('live health polling uses translated ON and OFF labels', () => {
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8');
|
||||
assert.match(source, /card\.status\.on/);
|
||||
assert.match(source, /card\.status\.off/);
|
||||
assert.doesNotMatch(source, /pill\.textContent\s*=\s*up\s*\?\s*['"]ON['"]\s*:\s*['"]OFF['"]/);
|
||||
});
|
||||
|
||||
|
||||
test('version modal escapes malicious API metadata before innerHTML rendering', () => {
|
||||
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||
const script = html.match(/<script>\s*\(function\(\) \{[\s\S]*?function escapeHtml[\s\S]*?window\.applyVersionUpdate = applyVersionUpdate;[\s\S]*?<\/script>/);
|
||||
assert.ok(script, 'expected inline version modal script');
|
||||
const escapeSource = script[0].match(/function escapeHtml\(value\) \{[\s\S]*?\n \}/)[0];
|
||||
const formatSource = script[0].match(/function formatValue\(value\) \{[\s\S]*?\n \}/)[0];
|
||||
const rowSource = script[0].match(/function renderInfoRow\(label, value\) \{[\s\S]*?\n \}/)[0];
|
||||
const context = {};
|
||||
vm.runInNewContext(escapeSource + '\n' + formatSource + '\n' + rowSource, context);
|
||||
const payload = '<img src=x onerror="globalThis.pwned=1">';
|
||||
const row = context.renderInfoRow(payload, payload);
|
||||
assert.doesNotMatch(row, /<img\b/i);
|
||||
assert.doesNotMatch(row, /onerror="/i);
|
||||
assert.match(row, /<img/);
|
||||
assert.match(row, /"globalThis\.pwned=1"/);
|
||||
});
|
||||
|
||||
test('index loads the generated core bundle containing live-status translation logic', () => {
|
||||
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||
const bundle = fs.readFileSync(path.join(statusRoot, 'dist', 'core.js'), 'utf8');
|
||||
assert.match(html, /<script src="\/dist\/core\.js" defer><\/script>/);
|
||||
assert.match(bundle, /data-i18n-live-status/);
|
||||
assert.match(bundle, /card\.status\.on/);
|
||||
assert.match(bundle, /applyTranslations/);
|
||||
});
|
||||
|
||||
test('translated controls preserve their visual glyph prefixes', () => {
|
||||
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||
assert.match(html, /data-i18n="filter\.online" data-i18n-prefix="🟢 "/);
|
||||
assert.match(html, /data-i18n="filter\.offline" data-i18n-prefix="🔴 "/);
|
||||
assert.match(html, /data-i18n="filter\.batch_operations" data-i18n-prefix="☰ "/);
|
||||
assert.match(html, /data-i18n-placeholder="filter\.services_placeholder" data-i18n-prefix="🔍 "/);
|
||||
});
|
||||
Reference in new issue
Block a user