[glm-grade=B+] feat(i18n): complete card/filter/action translation keys for 31 languages

Full language display names + RTL set (ar/fa/ur) on /i18n/languages;
card.internet/auth/tailscale/dashca, status pills, filter bar and
batch-operation strings added to every language dictionary. Frontend:
English now loads the server dictionary too (keys are semantic ids,
not fallback copy), failed loads keep existing DOM text instead of
exposing raw keys, isLoaded() gate for pre-load renders. Rebuilt
status/dist. Tests: i18n-cards 9/9, full suite 1837/1837.
This commit is contained in:
Krystie committed 2026-08-15 00:01:17 -07:00
1 parent bd40fb1c17
commit ef685e515e
11 files changed
+495 -167

No files matched your search

@@ -24,13 +24,29 @@ describe('DC-077: i18n Routes', () => {
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 () => {
+6 -8
View File
@@ -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,
});
+62
View File
@@ -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': '批量操作',
},
};
+106 -106
View File
@@ -1,4 +1,4 @@
(function(l){"use strict";class o{constructor(){this.errors=[],this.maxErrors=50}logError(h,f,m={}){const b={timestamp:new Date().toISOString(),context:h,message:f instanceof Error?f.message:f,stack:f instanceof Error?f.stack:null,metadata:m};this.errors.push(b),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${h}:`,f,m)}recoverFromError(h,f){switch(this.classifyError(h)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",h,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",h),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",h),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",h,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",h),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",h,{currentStep:f}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(h){const f=h.message||h.toString();return f.includes("element")&&f.includes("not found")?"ELEMENT_NOT_FOUND":f.includes("storage")||f.includes("quota")?"STORAGE_UNAVAILABLE":f.includes("driver")||f.includes("undefined")?"DRIVER_NOT_LOADED":f.includes("invalid")||f.includes("validation")?"INVALID_TOOLTIP":f.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const h={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(f=>{h.byContext[f.context]=(h.byContext[f.context]||0)+1;const m=this.classifyError({message:f.message});h.byType[m]=(h.byType[m]||0)+1}),h}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const h=document.createElement("div");return h.id="onboarding-fallback",h.style.cssText=`
(function(l){"use strict";class a{constructor(){this.errors=[],this.maxErrors=50}logError(b,h,u={}){const y={timestamp:new Date().toISOString(),context:b,message:h instanceof Error?h.message:h,stack:h instanceof Error?h.stack:null,metadata:u};this.errors.push(y),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${b}:`,h,u)}recoverFromError(b,h){switch(this.classifyError(b)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",b,{currentStep:h}),{action:"SKIP_STEP",nextStep:h+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",b),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",b),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",b,{currentStep:h}),{action:"SKIP_STEP",nextStep:h+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",b),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",b,{currentStep:h}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(b){const h=b.message||b.toString();return h.includes("element")&&h.includes("not found")?"ELEMENT_NOT_FOUND":h.includes("storage")||h.includes("quota")?"STORAGE_UNAVAILABLE":h.includes("driver")||h.includes("undefined")?"DRIVER_NOT_LOADED":h.includes("invalid")||h.includes("validation")?"INVALID_TOOLTIP":h.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const b={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(h=>{b.byContext[h.context]=(b.byContext[h.context]||0)+1;const u=this.classifyError({message:h.message});b.byType[u]=(b.byType[u]||0)+1}),b}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const b=document.createElement("div");return b.id="onboarding-fallback",b.style.cssText=`
position: fixed;
bottom: 20px;
right: 20px;
@@ -10,32 +10,32 @@
z-index: 9999;
max-width: 300px;
font-size: 14px;
`,h.innerHTML=`
`,b.innerHTML=`
<strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started.
</p>
`,document.body.appendChild(h),setTimeout(()=>{h.parentNode&&h.parentNode.removeChild(h)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const h={data:{},getItem(f){return this.data[f]||null},setItem(f,m){this.data[f]=m},removeItem(f){delete this.data[f]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),h}sendToErrorTracking(h){}}l.ErrorHandler=o,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var o=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(o){var g=JSON.parse(o);g.statsPollingInterval&&g.statsPollingInterval>=5&&g.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=g.statsPollingInterval*1e3)}}catch{}})();const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:typeof window<"u"&&window.__DC_STATS_OVERRIDE||5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const h=await fetch("/api/v1/config");if(h.ok){const f=await h.json();if(f.tld&&(SITE.tld=f.tld.startsWith(".")?f.tld:"."+f.tld),f.dns&&(SITE.dnsIp=f.dns.ip||"",SITE.dnsPort=f.dns.port||DC.DEFAULTS.DNS_PORT),f.dnsServers&&typeof f.dnsServers=="object")for(const[b,r]of Object.entries(f.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=r);f.configurationType&&(SITE.configurationType=f.configurationType),f.domain&&(SITE.domain=f.domain),f.defaults&&(SITE.defaults=f.defaults),f.routingMode&&(SITE.routingMode=f.routingMode),SITE.onboardingCompleted=f.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const m=document.getElementById("manage-tokens");m&&(m.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(h=>h.textContent=SITE.tld);const o=document.getElementById("edit-tld-suffix");o&&(o.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const o=SITE.dnsServers[l];return o?`${o.ip}:${o.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,o]of Object.entries(SITE.dnsServers))if(o.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)return;const o=Object.keys(SITE.dnsServers);if(!o.length)return;const g='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',h=l.firstElementChild;o.forEach(f=>{const m=escapeHtml(f),b=escapeHtml((SITE.dnsServers[f].name||f).toUpperCase()),r=document.createElement("div");r.className="card",r.setAttribute("data-app",f),r.setAttribute("data-status","off"),r.innerHTML=`<span id="${m}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${g}</div><span class="name">${b}</span><span class="spacer"></span><span id="${m}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${m}-time" class="response-time">--</span></div><div class="health-row" id="health-${m}"><span id="uptime-${m}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${m}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${m}-restart" class="restart-btn">Restart</button><button id="${m}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${m}-open">Open</button><button id="${m}-logs" class="logs-btn">Logs</button><button id="${m}-sLine truncated
`,document.body.appendChild(b),setTimeout(()=>{b.parentNode&&b.parentNode.removeChild(b)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const b={data:{},getItem(h){return this.data[h]||null},setItem(h,u){this.data[h]=u},removeItem(h){delete this.data[h]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),b}sendToErrorTracking(b){}}l.ErrorHandler=a,console.log("[ErrorHandler] Module loaded")})(window),(function(){try{var a=typeof localStorage<"u"&&localStorage.getItem("dashcaddy-health-settings")||null;if(a){var f=JSON.parse(a);f.statsPollingInterval&&f.statsPollingInterval>=5&&f.statsPollingInterval<=3600&&(window.__DC_STATS_OVERRIDE=f.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 b=await fetch("/api/v1/config");if(b.ok){const h=await b.json();if(h.tld&&(SITE.tld=h.tld.startsWith(".")?h.tld:"."+h.tld),h.dns&&(SITE.dnsIp=h.dns.ip||"",SITE.dnsPort=h.dns.port||DC.DEFAULTS.DNS_PORT),h.dnsServers&&typeof h.dnsServers=="object")for(const[y,c]of Object.entries(h.dnsServers))y!=="__proto__"&&y!=="constructor"&&y!=="prototype"&&(SITE.dnsServers[y]=c);h.configurationType&&(SITE.configurationType=h.configurationType),h.domain&&(SITE.domain=h.domain),h.defaults&&(SITE.defaults=h.defaults),h.routingMode&&(SITE.routingMode=h.routingMode),SITE.onboardingCompleted=h.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const u=document.getElementById("manage-tokens");u&&(u.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(b=>b.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(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const a=SITE.dnsServers[l];return a?`${a.ip}:${a.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,a]of Object.entries(SITE.dnsServers))if(a.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)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>',b=l.firstElementChild;a.forEach(h=>{const u=escapeHtml(h),y=escapeHtml((SITE.dnsServers[h].name||h).toUpperCase()),c=document.createElement("div");c.className="card",c.setAttribute("data-app",h),c.setAttribute("data-status","off"),c.innerHTML=`<span id="${u}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${f}</div><span class="name">${y}</span><span class="spacer"></span><span id="${u}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div><div class="response-row"><span id="${u}-time" class="response-time">--</span></div><div class="health-row" id="health-${u}"><span id="uptime-${u}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${u}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${u}-restart" class="restart-btn" data-i18n="action.restart">Restart</button><button id="${u}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${u}-open" data-i18n="action.open">OpLine truncated
position: fixed; top: 20px; right: 20px;
background: ${m.bg}; color: ${m.fg};
background: ${u.bg}; color: ${u.fg};
padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px;
`,document.body.appendChild(b),g>0&&setTimeout(()=>b.remove(),g)}function timeAgo(l){const o=Date.now()-new Date(l).getTime();return o<6e4?"just now":o<36e5?Math.floor(o/6e4)+"m ago":o<864e5?Math.floor(o/36e5)+"h ago":Math.floor(o/864e5)+"d ago"}function safeGet(l,o=null){try{const g=localStorage.getItem(l);return g!==null?g:o}catch{return o}}function safeSet(l,o){try{localStorage.setItem(l,o)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,o=null){try{const g=sessionStorage.getItem(l);return g!==null?g:o}catch{return o}}function safeSessionSet(l,o){try{sessionStorage.setItem(l,o)}catch{}}function safeGetJSON(l,o=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):o}catch{return o}}function escapeHtml(l){return String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(l,o){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",o)}const DC_BUS={_handlers:{},on(l,o){var g;((g=this._handlers)[l]||(g[l]=[])).push(o)},off(l,o){this._handlers[l]=this._handlers[l]?.filter(g=>g!==o)},emit(l,o){this._handlers[l]?.forEach(g=>g(o))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(o=>o.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const o=this._apps.findIndex(g=>g.id===l);return o>-1&&(this._apps.splice(o,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),o>-1},updateApp(l,o){const g=this._apps.find(h=>h.id===l);if(g){for(const[h,f]of Object.entries(o))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(g[h]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const h=document.createElement("div");return h.className="skeleton-card",h.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',h}function o(h){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){h=h||6;for(let m=0;m<h;m++){const b=l();f.appendChild(b),setTimeout(function(){b.classList.add("loaded")},m*60)}}}function g(){const h=document.getElementById("cards");if(h){var f=h.querySelectorAll(".skeleton-card");f.length&&(f.forEach(function(m,b){setTimeout(function(){m.style.opacity="0",m.style.transform="translateY(-10px)"},b*25)}),setTimeout(function(){f.forEach(function(m){m.parentNode&&m.remove()})},f.length*25+300))}}window.SkeletonLoader={show:o,hide:g}})(),(function(){var l="theme",o="user-themes",g="custom-theme",h=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],f=h.slice(),m=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],b=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],r=["fg-muted","hover","card-hover","base","success","error","warning"],v={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
`,document.body.appendChild(y),f>0&&setTimeout(()=>y.remove(),f)}function timeAgo(l){const a=Date.now()-new Date(l).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(l,a=null){try{const f=localStorage.getItem(l);return f!==null?f:a}catch{return a}}function safeSet(l,a){try{localStorage.setItem(l,a)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,a=null){try{const f=sessionStorage.getItem(l);return f!==null?f:a}catch{return a}}function safeSessionSet(l,a){try{sessionStorage.setItem(l,a)}catch{}}function safeGetJSON(l,a=null){try{const f=localStorage.getItem(l);return f?JSON.parse(f):a}catch{return a}}function escapeHtml(l){return String(l??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(l,a){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",a)}const DC_BUS={_handlers:{},on(l,a){var f;((f=this._handlers)[l]||(f[l]=[])).push(a)},off(l,a){this._handlers[l]=this._handlers[l]?.filter(f=>f!==a)},emit(l,a){this._handlers[l]?.forEach(f=>f(a))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(a=>a.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const a=this._apps.findIndex(f=>f.id===l);return a>-1&&(this._apps.splice(a,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),a>-1},updateApp(l,a){const f=this._apps.find(b=>b.id===l);if(f){for(const[b,h]of Object.entries(a))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(f[b]=h);DC_BUS.emit("apps:changed",this._apps)}return f}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.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>',b}function a(b){const h=document.getElementById("cards");if(!(!h||h.querySelector(".card"))){b=b||6;for(let u=0;u<b;u++){const y=l();h.appendChild(y),setTimeout(function(){y.classList.add("loaded")},u*60)}}}function f(){const b=document.getElementById("cards");if(b){var h=b.querySelectorAll(".skeleton-card");h.length&&(h.forEach(function(u,y){setTimeout(function(){u.style.opacity="0",u.style.transform="translateY(-10px)"},y*25)}),setTimeout(function(){h.forEach(function(u){u.parentNode&&u.remove()})},h.length*25+300))}}window.SkeletonLoader={show:a,hide:f}})(),(function(){var l="theme",a="user-themes",f="custom-theme",b=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],h=b.slice(),u=["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"],y=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],c=["fg-muted","hover","card-hover","base","success","error","warning"],p={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%),
radial-gradient(1200px 800px at 10% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%),
var(--bg);
}
`:":root."+C+` body {
background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+x.r+","+x.g+","+x.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%),
var(--bg);
}
`}function c(C,B){var $=B.lightBg||B.bg&&s(B.bg)>.4;return $?":root."+C+` button:hover {
`}function s(C,B){var $=B.lightBg||B.bg&&i(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(o,{}),x=$,I=2;k[$]&&$!==B;)$=x+"-"+I++;return $}function u(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=h.length,Object.keys(v).forEach(function(E){h.indexOf(E)===-1&&delete v[E]});var $=C||safeGetJSON(o,{}),k=Object.keys($);if(k=k.filter(function(E){return h.indexOf(E)===-1}),!!k.length){var x="";k.forEach(function(E){var S=$[E];f.indexOf(E)===-1&&f.push(E);var P={};m.forEach(function(O){S[O]&&(P[O]=S[O])}),P["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(P.lightBg=!0);var D=e(P);r.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),v[E]=P,x+=":root."+E+` {
`,m.forEach(function(O){P[O]&&(x+=" --"+O+": "+P[O]+`;
`)}),x+=`}
`,x+=n(E,P),x+=c(E,P)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(o,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(o,JSON.stringify(B)),u(B);var k=safeGet(l);k&&f.indexOf(k)!==-1&&L(k)}}}).catch(function(){})}function T(){var C=safeGetJSON(g);if(C){var B=C.name||"Custom",$=y(B),k={name:B};m.forEach(function(E){C[E]&&(k[E]=C[E])});var x=safeGetJSON(o,{});x[$]=k,safeSet(o,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};m.forEach(function(E){k[E]&&(I[E]=k[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),i(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(l,C);var B=v[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var k=B&&B.lightBg;!k&&B&&B.bg&&(k=s(B.bg)>.4),k?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),u();var A=safeGet(l);A==="red"&&(A="black",safeSet(l,"black")),A&&A!=="dark"&&f.indexOf(A)===-1&&(A=null),L(A||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(l)||L(C.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=h,window.THEME_COLORS=v,window.THEME_PROPS=m,window.BASE_PROPS=b,window.DERIVED_PROPS=r,window.USER_THEMES_KEY=o,window.applyTheme=L,window.clearCustomProperties=i,window.injectUserThemeStyles=u,window.syncThemesFromServer=w,window.slugifyThemeName=y,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=d,window.rgbToHex=a,window.blendColors=p})(),(function(){let l=null;async function o(){if(l)return l;try{const d=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!d.ok)throw new Error(`methods HTTP ${d.status}`);const a=await d.json();return l=Array.isArray(a.providers)?a.providers:[],l}catch(d){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",d),[]}}function g(d){const a=document.getElementById("totp-overlay");if(!a)return;const p=a.querySelector(".totp-card");if(!p)return;const s=p.innerHTML;p.dataset.originalBody||(p.dataset.originalBody=s);const e=d.map(n=>{const c=n.config&&(n.config.label||n.name)||n.name;return`<button class="provider-btn" data-provider="${n.name}"
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function r(){u.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function g(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var E=safeGetJSON(a,{}),w=$,S=2;E[$]&&$!==B;)$=w+"-"+S++;return $}function m(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),h.length=b.length,Object.keys(p).forEach(function(T){b.indexOf(T)===-1&&delete p[T]});var $=C||safeGetJSON(a,{}),E=Object.keys($);if(E=E.filter(function(T){return b.indexOf(T)===-1}),!!E.length){var w="";E.forEach(function(T){var k=$[T];h.indexOf(T)===-1&&h.push(T);var A={};u.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);c.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),p[T]=A,w+=":root."+T+` {
`,u.forEach(function(O){A[O]&&(w+=" --"+O+": "+A[O]+`;
`)}),w+=`}
`,w+=e(T,A),w+=s(T,A)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=w,document.head.appendChild(S)}}function x(){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)),m(B);var E=safeGet(l);E&&h.indexOf(E)!==-1&&L(E)}}}).catch(function(){})}function I(){var C=safeGetJSON(f);if(C){var B=C.name||"Custom",$=g(B),E={name:B};u.forEach(function(T){C[T]&&(E[T]=C[T])});var w=safeGetJSON(a,{});w[$]=E,safeSet(a,JSON.stringify(w)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(f);var S={};u.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"),h.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),r(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(l,C);var B=p[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var E=B&&B.lightBg;!E&&B&&B.bg&&(E=i(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(),m();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&h.indexOf(P)===-1&&(P=null),L(P||n()),x(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(l)||L(C.matches?"dark":"light")}),window.THEMES=h,window.BUILTIN_THEMES=b,window.THEME_COLORS=p,window.THEME_PROPS=u,window.BASE_PROPS=y,window.DERIVED_PROPS=c,window.USER_THEMES_KEY=a,window.applyTheme=L,window.clearCustomProperties=r,window.injectUserThemeStyles=m,window.syncThemesFromServer=x,window.slugifyThemeName=g,window.getActiveTheme=function(){return safeGet(l)||n()},window.deriveExtendedColors=t,window.hexToRgb=d,window.rgbToHex=o,window.blendColors=v})(),(function(){let l=null;async function a(){if(l)return l;try{const d=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!d.ok)throw new Error(`methods HTTP ${d.status}`);const o=await d.json();return l=Array.isArray(o.providers)?o.providers:[],l}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 v=o.querySelector(".totp-card");if(!v)return;const i=v.innerHTML;v.dataset.originalBody||(v.dataset.originalBody=i);const t=d.map(e=>{const s=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 ${c}
Sign in with ${s}
</button>`}).join(`
`);p.innerHTML=`
`);v.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>
`,a.classList.add("show"),p.querySelectorAll(".provider-btn").forEach(n=>{n.addEventListener("click",()=>{const c=n.dataset.provider,t=d.find(i=>i.name===c);f(t)})})}function h(){const d=document.getElementById("totp-overlay");if(!d)return;const a=d.querySelector(".totp-card");!a||!a.dataset.originalBody||(a.innerHTML=a.dataset.originalBody,window.location.reload())}function f(d){const a=document.getElementById("totp-overlay");if(!a)return;const p=a.querySelector(".totp-card");if(p){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){p.innerHTML=`
`,o.classList.add("show"),v.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const s=e.dataset.provider,n=d.find(r=>r.name===s);h(n)})})}function b(){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 h(d){const o=document.getElementById("totp-overlay");if(!o)return;const v=o.querySelector(".totp-card");if(v){if(d.name==="totp"){window.location.reload();return}if(d.name==="email"){v.innerHTML=`
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
<p class="subtitle">Sign in with email</p>
@@ -75,13 +75,13 @@
<div style="margin-top: 16px;">
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
</div>
`,a.classList.add("show");const s=p.querySelector("#auth-gate-email-input"),e=p.querySelector("#auth-gate-email-submit"),n=p.querySelector("#auth-gate-email-status"),c=p.querySelector("#auth-gate-back");e.addEventListener("click",async()=>{const t=(s.value||"").trim();if(!t||!t.includes("@")){n.textContent="Enter a valid email address.",n.style.color="var(--error, #d33)";return}e.disabled=!0,n.textContent="Sending\u2026",n.style.color="";try{const y=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:t})})).json();if(y.success){const u=y.deliveredVia||"email",w=y.maskedEmail||t;u==="dev-console"?n.innerHTML=`
`,o.classList.add("show");const i=v.querySelector("#auth-gate-email-input"),t=v.querySelector("#auth-gate-email-submit"),e=v.querySelector("#auth-gate-email-status"),s=v.querySelector("#auth-gate-back");t.addEventListener("click",async()=>{const n=(i.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 g=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})})).json();if(g.success){const m=g.deliveredVia||"email",x=g.maskedEmail||n;m==="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()}),c.addEventListener("click",t=>{t.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function m(){if(!document.getElementById("totp-overlay"))return;const a=await o();if(a.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(a.length===1&&a[0].name==="totp"){b(a[0]);return}g(a)}function b(d){const a=l&&l.find(n=>n.name==="email");if(!a){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const p=document.getElementById("totp-overlay"),s=p.querySelector(".totp-card");if(!s)return;if(s.dataset.originalBody||(s.dataset.originalBody=s.innerHTML),!s.querySelector("#auth-gate-email-alt")){const n=document.createElement("div");n.id="auth-gate-email-alt",n.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",n.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
(Dev mode: no SMTP configured. In production this would email <strong>${x}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${x}</strong>.
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=g.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}}),i.addEventListener("keydown",n=>{n.key==="Enter"&&t.click()}),s.addEventListener("click",n=>{n.preventDefault(),f(providers)});return}console.warn("[auth-gate] unknown provider",d.name),window.location.reload()}}async function u(){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"){y(o[0]);return}f(o)}function y(d){const o=l&&l.find(e=>e.name==="email");if(!o){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const v=document.getElementById("totp-overlay"),i=v.querySelector(".totp-card");if(!i)return;if(i.dataset.originalBody||(i.dataset.originalBody=i.innerHTML),!i.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",c=>{c.preventDefault(),f(a)})}p.classList.add("show")}window.__dc_049_handled=!0;function r(d){try{const a=new URL(d,window.location.origin);if(!["http:","https:"].includes(a.protocol))return!1;if(a.origin===window.location.origin)return!0;if(a.protocol!=="https:")return!1;const p=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return a.hostname===p.slice(1)||a.hostname.endsWith(p)}catch{return!1}}const v=new URLSearchParams(window.location.search);if(v.get("auth")==="required"){const d=v.get("return");if(d&&r(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(m,0)}window._showAuthGate=m,window._authGateMethods=o})(),(function(){function l(){const r=document.querySelector(".totp-card");if(!r)return;const d=getComputedStyle(r).backgroundColor.match(/\d+/g);if(!d)return;const a=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,p=r.querySelector(".totp-logo-dark"),s=r.querySelector(".totp-logo-light");p&&(p.style.display=a>.5?"none":""),s&&(s.style.display=a>.5?"":"none")}function o(){const r=document.getElementById("totp-overlay");if(r){r.classList.add("show"),setTimeout(l,50);const v=r.querySelector(".totp-digits input");v&&setTimeout(()=>v.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const r=document.getElementById("totp-overlay");r&&r.classList.remove("show")}function h(r,v){const d=new URL(r,window.location.origin);if(d.origin===window.location.origin)return d.toString();const a=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,p=d.hostname===a.slice(1)||d.hostname.endsWith(a);if(d.protocol!=="https:"||!p)return null;if(!v)return d.toString();const s=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",v),d.searchParams.set("return",s),d.toString()}const f=document.getElementById("totp-digits");if(f){const r=f.querySelectorAll("input");r.forEach((v,d)=>{v.addEventListener("input",a=>{const p=a.target.value.replace(/\D/g,"");a.target.value=p.slice(0,1),p&&d<r.length-1&&r[d+1].focus();const s=Array.from(r).map(e=>e.value).join("");s.length===6&&m(s)}),v.addEventListener("keydown",a=>{a.key==="Backspace"&&!a.target.value&&d>0&&(r[d-1].focus(),r[d-1].value="")}),v.addEventListener("paste",a=>{a.preventDefault();const p=(a.clipboardData.getData("text")||"").replace(/\D/g,"");p.length>=6&&(r.forEach((s,e)=>{s.value=p[e]||""}),r[5].focus(),m(p.slice(0,6)))})})}async function m(r){const v=document.getElementById("totp-error");v.textContent="Verifying...",v.className="totp-error verifying";try{const a=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:r})})).json();if(a.success){v.textContent="",a.csrfToken&&(csrfToken=a.csrfToken),g();const p=safeSessionGet("totp_redirect");if(p){try{sessionStorage.removeItem("totp_redirect")}catch{}const s=h(p,a.ssoToken);if(!s)return;window.location.href=s;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{v.textContent=a.error||"Invalid code",v.className="totp-error";const p=document.querySelectorAll("#totp-digits input");p.forEach(s=>{s.value=""}),p[0]?.focus()}}catch{v.textContent="Connection error",v.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const r=urlParams.get("return");if(r)try{const v=new URL(r,window.location.origin),d=v.hostname,a=v.origin===window.location.origin,p=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,s=d.endsWith(p)||d===p.substring(1);(a||s)&&safeSessionSet("totp_redirect",r)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=o})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function o(b){const r=document.getElementById("totp-recovery-link");r&&(r.style.display=b?"":"none")}function g(){const b=document.getElementById("totp-recovery-panel");b&&(b.style.display="");const r=document.getElementById("totp-recovery-status"),v=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");v&&(v.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(a=>{r.textContent=a.hint||"",a.status==="healthy"?r.style.borderColor="var(--ok-fg, #7ef2ff)":a.status==="unreadable"?(r.style.borderColor="var(--bad-fg, #ff9aa3)",r.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):a.status==="not_configured"?r.style.borderColor="var(--muted)"Line truncated
</a>`,i.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",s=>{s.preventDefault(),h(o)})}v.classList.add("show")}window.__dc_049_handled=!0;function c(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 v=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`;return o.hostname===v.slice(1)||o.hostname.endsWith(v)}catch{return!1}}const p=new URLSearchParams(window.location.search);if(p.get("auth")==="required"){const d=p.get("return");if(d&&c(d))try{sessionStorage.setItem("totp_redirect",d)}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(u,0)}window._showAuthGate=u,window._authGateMethods=a})(),(function(){function l(){const c=document.querySelector(".totp-card");if(!c)return;const d=getComputedStyle(c).backgroundColor.match(/\d+/g);if(!d)return;const o=(.299*+d[0]+.587*+d[1]+.114*+d[2])/255,v=c.querySelector(".totp-logo-dark"),i=c.querySelector(".totp-logo-light");v&&(v.style.display=o>.5?"none":""),i&&(i.style.display=o>.5?"":"none")}function a(){const c=document.getElementById("totp-overlay");if(c){c.classList.add("show"),setTimeout(l,50);const p=c.querySelector(".totp-digits input");p&&setTimeout(()=>p.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function f(){const c=document.getElementById("totp-overlay");c&&c.classList.remove("show")}function b(c,p){const d=new URL(c,window.location.origin);if(d.origin===window.location.origin)return d.toString();const o=SITE.tld.startsWith(".")?SITE.tld:`.${SITE.tld}`,v=d.hostname===o.slice(1)||d.hostname.endsWith(o);if(d.protocol!=="https:"||!v)return null;if(!p)return d.toString();const i=`${d.pathname}${d.search}${d.hash}`;return d.pathname="/dashcaddy-sso",d.search="",d.hash="",d.searchParams.set("token",p),d.searchParams.set("return",i),d.toString()}const h=document.getElementById("totp-digits");if(h){const c=h.querySelectorAll("input");c.forEach((p,d)=>{p.addEventListener("input",o=>{const v=o.target.value.replace(/\D/g,"");o.target.value=v.slice(0,1),v&&d<c.length-1&&c[d+1].focus();const i=Array.from(c).map(t=>t.value).join("");i.length===6&&u(i)}),p.addEventListener("keydown",o=>{o.key==="Backspace"&&!o.target.value&&d>0&&(c[d-1].focus(),c[d-1].value="")}),p.addEventListener("paste",o=>{o.preventDefault();const v=(o.clipboardData.getData("text")||"").replace(/\D/g,"");v.length>=6&&(c.forEach((i,t)=>{i.value=v[t]||""}),c[5].focus(),u(v.slice(0,6)))})})}async function u(c){const p=document.getElementById("totp-error");p.textContent="Verifying...",p.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:c})})).json();if(o.success){p.textContent="",o.csrfToken&&(csrfToken=o.csrfToken),f();const v=safeSessionGet("totp_redirect");if(v){try{sessionStorage.removeItem("totp_redirect")}catch{}const i=b(v,o.ssoToken);if(!i)return;window.location.href=i;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{p.textContent=o.error||"Invalid code",p.className="totp-error";const v=document.querySelectorAll("#totp-digits input");v.forEach(i=>{i.value=""}),v[0]?.focus()}}catch{p.textContent="Connection error",p.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const c=urlParams.get("return");if(c)try{const p=new URL(c,window.location.origin),d=p.hostname,o=p.origin===window.location.origin,v=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,i=d.endsWith(v)||d===v.substring(1);(o||i)&&safeSessionSet("totp_redirect",c)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=a})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function a(y){const c=document.getElementById("totp-recovery-link");c&&(c.style.display=y?"":"none")}function f(){const y=document.getElementById("totp-recovery-panel");y&&(y.style.display="");const c=document.getElementById("totp-recovery-status"),p=document.getElementById("totp-recovery-import"),d=document.getElementById("totp-recovery-verify");p&&(p.style.display=""),d&&(d.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(o=>{c.textContent=o.hint||"",o.status==="healthy"?c.style.borderColor="var(--ok-fg, #7ef2ff)":o.status==="unreadable"?(c.style.borderColor="var(--bad-fg, #ff9aa3)",c.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):o.status==="not_configured"?c.style.borderColor="var(--muted)"Line truncated
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -179,7 +179,7 @@
</button>
</div>
</div>
</div>`);const o=document.getElementById("service-creds-modal");let g=null;const h=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function m(a){return a.externalUrl||a.url||""}function b(a){const p=document.getElementById("svc-creds-error");p.textContent=a,p.style.display=""}function r(){const a=document.getElementById("svc-creds-error");a.textContent="",a.style.display="none"}window.openServiceCredsModal=async function(a){g=a,r();const p=document.getElementById("svc-creds-title"),s=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),n=document.getElementById("svc-creds-apikey"),c=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");p.textContent=a.name+" Credentials";const i=!!a.isExternal,y=h.includes(a.id)||h.includes(a.appTemplate),u=f.includes(a.id)||f.includes(a.appTemplate);e.style.display=i?"":"none",n.style.display=y?"":"none",t.style.display=u?"":"none",c.style.display=i?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",i?(s.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${a.name}`):y?s.textContent="API key bypasses the app login screen automatically.":s.textContent="Credentials are injected automatically when accessing this service.",await v(a),o.classList.add("show")};async function v(a){const p=document.getElementById("svc-creds-dot"),s=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let n=!1;if(a.isExternal){try{const i=await(await fetch(`/api/v1/seedhost-creds?serviceId=${a.id}`)).json();i.success?(document.getElementById("svc-seedhost-user").value=i.username||"",i.hasCredentials&&(n=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const i=await(await fetch(`/api/v1/services/${a.id}/credentials`)).json();i.success&&(i.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",n=!0):document.getElementById("svc-apikey-input").value="",i.hasBasicAuth&&!a.isExternal?(document.getElementById("svc-basic-user").value=i.username||"",n=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const c=a.id||a.appTemplate;if(f.includes(c)&&await d(a),n){p.style.background="var(--ok-fg, #74dfc4)",s.style.color="var(--ok-fg, #74dfc4)",s.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${a.id}`);t&&t.classList.add("has-creds")}else p.style.background="var(--muted)",s.style.color="var(--muted)",s.textContent="No credentials stored",e.style.display="none"}async function d(a){const p=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status"),e=a.id||a.appTemplate,n=m(a);if(!n){p.innerHTML='<option value="">-- No service URL --</option>';return}p.innerHTML='<option value="">Loading...</option>',s.textContent="";try{const c=new URLSearchParams({service:e,url:n}),i=await(await fetch(`/api/v1/arr/quality-profiles?${c}`)).json();if(!i.success||!i.profiles?.length){p.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}p.innerHTML="";for(const y of i.profiles){const u=document.createElement("option");u.value=y.id,u.textContent=y.name,p.appendChild(u)}if(i.storedProfileId&&(p.value=String(i.storedProfileId)),!p.value){const y=i.profiles.find(u=>/720/i.test(u.name));y&&(p.value=String(y.id))}!p.value&&i.profiles.length&&(p.value=String(i.profiles[0].id)),s.innerHTML=`<span style="color: var(--ok-fg);">${i.profiles.length} profiles loaded</span>`}catch(c){p.innerHTML='<option value="">-- Failed to load --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${c.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const a=g.id||g.appTemplate,p=m(g),e=document.getElementById("svc-apikey-input")?.value.trim(),n=document.getElementById("svc-quality-select"),c=document.getElementById("svc-quality-status");if(!p){c.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){c.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}n.innerHTML='<option value="">Fetching...</option>',c.textContent="";try{const t=new URLSearchParams({service:a,url:p,apiKey:e}),y=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!y.success){n.innerHTML='<option value="">-- Error --</option>',c.innerHTML=`<span style="color: var(--error, #c62828);">${y.error||"Failed to feLine truncated
</div>`);const a=document.getElementById("service-creds-modal");let f=null;const b=["sonarr","radarr","prowlarr","overseerr"],h=["sonarr","radarr"];function u(o){return o.externalUrl||o.url||""}function y(o){const v=document.getElementById("svc-creds-error");v.textContent=o,v.style.display=""}function c(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){f=o,c();const v=document.getElementById("svc-creds-title"),i=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),s=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");v.textContent=o.name+" Credentials";const r=!!o.isExternal,g=b.includes(o.id)||b.includes(o.appTemplate),m=h.includes(o.id)||h.includes(o.appTemplate);t.style.display=r?"":"none",e.style.display=g?"":"none",n.style.display=m?"":"none",s.style.display=r?"none":"";const x=document.getElementById("svc-quality-select");x.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",r?(i.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}`):g?i.textContent="API key bypasses the app login screen automatically.":i.textContent="Credentials are injected automatically when accessing this service.",await p(o),a.classList.add("show")};async function p(o){const v=document.getElementById("svc-creds-dot"),i=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 s=o.id||o.appTemplate;if(h.includes(s)&&await d(o),e){v.style.background="var(--ok-fg, #74dfc4)",i.style.color="var(--ok-fg, #74dfc4)",i.textContent="Credentials stored",t.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else v.style.background="var(--muted)",i.style.color="var(--muted)",i.textContent="No credentials stored",t.style.display="none"}async function d(o){const v=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status"),t=o.id||o.appTemplate,e=u(o);if(!e){v.innerHTML='<option value="">-- No service URL --</option>';return}v.innerHTML='<option value="">Loading...</option>',i.textContent="";try{const s=new URLSearchParams({service:t,url:e}),r=await(await fetch(`/api/v1/arr/quality-profiles?${s}`)).json();if(!r.success||!r.profiles?.length){v.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}v.innerHTML="";for(const g of r.profiles){const m=document.createElement("option");m.value=g.id,m.textContent=g.name,v.appendChild(m)}if(r.storedProfileId&&(v.value=String(r.storedProfileId)),!v.value){const g=r.profiles.find(m=>/720/i.test(m.name));g&&(v.value=String(g.id))}!v.value&&r.profiles.length&&(v.value=String(r.profiles[0].id)),i.innerHTML=`<span style="color: var(--ok-fg);">${r.profiles.length} profiles loaded</span>`}catch(s){v.innerHTML='<option value="">-- Failed to load --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${s.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!f)return;const o=f.id||f.appTemplate,v=u(f),t=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status");if(!v){s.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!t||t==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){s.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',s.textContent="";try{const n=new URLSearchParams({service:o,url:v,apiKey:t}),g=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!g.success){e.innerHTML='<option value="">-- Error --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">${g.error||"Failed to feLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -288,7 +288,7 @@
</button>
</div>
</div>
</div>`);async function o(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:b,sessionDuration:r,isSetUp:v}=m.config,d=document.getElementById("totp-status-dot"),a=document.getElementById("totp-status-text"),p=document.getElementById("totp-status-banner"),s=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),n=document.getElementById("totp-duration-section"),c=document.getElementById("totp-disable-section");if(b&&v){d.style.background="var(--ok-fg, #7ef2ff)",p.style.borderColor="var(--ok-fg, #7ef2ff)",p.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",a.textContent="TOTP is active",a.style.color="var(--ok-fg, #7ef2ff)",s.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",n.style.display="block",c.style.display="block",document.getElementById("totp-duration-select").value=r}else d.style.background="var(--muted)",p.style.borderColor="var(--border)",p.style.background="transparent",a.textContent="TOTP is not configured",a.style.color="var(--muted)",s.style.display="block",e.style.display="none",n.style.display="none",c.style.display="none";h(b&&v,r)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function h(f,m){const b=document.getElementById("auth-card"),r=document.getElementById("auth-pill"),v=document.getElementById("auth-dot"),d=document.getElementById("auth-status-text");b&&(f?(b.setAttribute("data-status","on"),r.className="badge on",r.textContent="YES",v.className="dot ok at-bl",d.textContent="Session: "+(g[m]||m)):(b.setAttribute("data-status","off"),r.className="badge off",r.textContent="NO",v.className="dot bad at-bl",d.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!f){m.textContent="Paste a Base32 secret key first";return}try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();r.success?(m.textContent="",document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):m.textContent=r.error||r.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const m={service:"DashCaddy",type:"totp-secret",secret:f,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},b=new Blob([JSON.stringify(m,null,2)],{type:"application/json"}),r=URL.createObjectURL(b),v=document.createElement("a");v.href=r,v.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(v),v.click(),document.body.removeChild(v),URL.revokeObjectURL(r);const d=document.getElementById("totp-download-backup");d.textContent="\u2705 Saved",setTimeout(()=>{d.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=doLine truncated
</div>`);async function a(){try{const y=await(await fetch("/api/v1/totp/config")).json();if(!y.success)return;const{enabled:c,sessionDuration:p,isSetUp:d}=y.config,o=document.getElementById("totp-status-dot"),v=document.getElementById("totp-status-text"),i=document.getElementById("totp-status-banner"),t=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),s=document.getElementById("totp-duration-section"),n=document.getElementById("totp-disable-section");if(c&&d){o.style.background="var(--ok-fg, #7ef2ff)",i.style.borderColor="var(--ok-fg, #7ef2ff)",i.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",v.textContent="TOTP is active",v.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",s.style.display="block",n.style.display="block",document.getElementById("totp-duration-select").value=p}else o.style.background="var(--muted)",i.style.borderColor="var(--border)",i.style.background="transparent",v.textContent="TOTP is not configured",v.style.color="var(--muted)",t.style.display="block",e.style.display="none",s.style.display="none",n.style.display="none";h(c&&d,p)}catch(u){console.warn("Failed to load TOTP settings:",u)}}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 b(u,y){const c=window.DCI18n;return c&&c.isLoaded()?c.t(u):y}function h(u,y){const c=document.getElementById("auth-card"),p=document.getElementById("auth-pill"),d=document.getElementById("auth-dot"),o=document.getElementById("auth-status-text");c&&(u?(c.setAttribute("data-status","on"),p.className="badge on",p.textContent=b("card.status.yes","YES"),d.className="dot ok at-bl",o.textContent="Session: "+(f[y]||y)):(c.setAttribute("data-status","off"),p.className="badge off",p.textContent=b("card.status.no","NO"),d.className="dot bad at-bl",o.textContent=b("card.auth.not_configured","Not configured")))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const y=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();y.success&&(document.getElementById("totp-qr-image").src=y.qrCode,document.getElementById("totp-manual-key").textContent=y.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(u){l.logError("[TOTP] Setup Failed",u,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const u=document.getElementById("totp-import-key").value.trim(),y=document.getElementById("totp-import-error");if(y.textContent="",!u){y.textContent="Paste a Base32 secret key first";return}try{const p=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:u})})).json();p.success?(y.textContent="",document.getElementById("totp-qr-image").src=p.qrCode,document.getElementById("totp-manual-key").textContent=p.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()):y.textContent=p.error||p.message||"Import failed"}catch{y.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const u=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(u).then(()=>{const y=document.getElementById("totp-copy-key");y.textContent="\u2705",setTimeout(()=>{y.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const u=document.getElementById("totp-manual-key").textContent.trim();if(!u)return;const y={service:"DashCaddy",type:"totp-secret",secret:u,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.'},c=new Blob([JSON.stringify(y,null,2)],{type:"application/json"}),p=URL.createObjectURL(c),d=document.createElement("a");d.href=p,d.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(d),d.click(),document.body.removeChild(d),URL.revokeObjectURL(p);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 l(){return Object.keys(SITE.dnsServers||{})}function o(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const i=l();if(i.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const y of i)t.insertAdjacentHTML("beforeend",`
`);function l(){return Object.keys(SITE.dnsServers||{})}function a(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function f(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const r=l();if(r.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const g of r)n.insertAdjacentHTML("beforeend",`
<div class="token-section">
<h4 class="token-section-title">${o(y)}</h4>
<h4 class="token-section-title">${a(g)}</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="${g}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${g}-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="${g}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${g}-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="${g}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${g}-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="${g}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${g}-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="${y}-token-status"></div>
<div class="token-status" id="${g}-token-status"></div>
</div>
`)}function h(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const i=safeGet("dashcaddy-encryption-key");if(i)return safeSessionSet("dashcaddy-encryption-key",i),safeRemove("dashcaddy-encryption-key"),i;const y=new Uint8Array(32);return crypto.getRandomValues(y),t=Array.from(y,u=>u.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=h();function m(t,i){if(!t)return"";const y=crypto.getRandomValues(new Uint8Array(8)),u=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(i+u);let T="";for(let L=0;L<t.length;L++){const A=t.charCodeAt(L)^w[L%w.length]^y[L%y.length]^L*31+17&255;T+=String.fromCharCode(A)}return u+":"+btoa(T)}function b(t,i){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),A=atob(t.substring(17)),C=new TextEncoder().encode(i+T);let B="";for(let $=0;$<A.length;$++){const k=A.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(k)}return B}const u=atob(t);let w="";for(let T=0;T<u.length;T++){const L=u.charCodeAt(T)^i.charCodeAt(T%i.length);w+=String.fromCharCode(L)}return w}catch{return""}}function r(t,i,y){const u=safeGet(`${t}-${i}-${y}-enc`);return b(u,f)}function v(t,i,y,u){const w=`${t}-${i}-${y}-enc`;u?safeSet(w,m(u,f)):safeRemove(w)}function d(t,i){return r(t,i,"token")}function a(t,i){return r(t,i,"username")}function p(t,i,y){v(t,i,"token",y)}function s(t,i,y){v(t,i,"username",y)}function e(){const t={};for(const i of l())t[i]={readonly:{username:a(i,"readonly"),token:d(i,"readonly")},admin:{username:a(i,"admin"),token:d(i,"admin")}};return t}function n(){l().forEach(t=>{["readonly","admin"].forEach(i=>{["token","username"].forEach(y=>{safeRemove(`${t}-${i}-${y}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function c(t){const i=d(t,"readonly"),y=a(t,"readonly"),u=d(t,"admin"),w=a(t,"admin"),T=b(safeGet(`${t}-token-enc`),f),L=b(safeGet(`${t}-username-enc`),f);return{username:w||y||L,token:u||i||T,readonlyToken:i||T,readonlyUsername:y||L,adminToken:u||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),i=e();l().forEach(y=>{const u=i[y];document.getElementById(`${y}-readonly-username`).value=u.readonly.username,document.getElementById(`${y}-readonly-token`).value=u.readonly.token,document.getElementById(`${y}-admin-username`).value=u.admin.username,document.getElementById(`${y}-admin-token`).value=u.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const i=t.target.closest(".token-toggle");if(i){const y=i.dataset.target,u=document.getElementById(y);u.type==="password"?(u.type="text",i.textContent="\u{1F648}"):(u.type="password",i.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(u=>{s(u,"readonly",document.getElementById(`${u}-readonly-username`).value.trim()),p(u,"readonly",document.getElementById(`${u}-readonly-token`).value.trim()),s(u,"admin",document.getElementById(`${u}-admin-username`).value.trim()),p(u,"admin",document.getElementById(`${u}-admin-token`).value.trim())});const i={};let y=!1;if(t.forEach(u=>{const w={},T=document.getElementById(`${u}-readonly-username`).value.trim(),L=document.getElementById(`${u}-readonly-token`).value.trim(),A=document.getElementById(`${u}-admin-username`).value.trim(),C=document.getElementById(`${u}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},y=!0),A&&C&&(w.admin={username:A,password:C},y=!0),Object.keys(w).length>0&&(i[u]=w)}),y){t.forEach(u=>{i[u]&&(document.getElementById(`${u}-token-status`).textContent="Verifying...",document.getElementById(`${u}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:i})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!i[T]){L.textContent="";return}const A=w.results[T];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{i[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(u){console.erro Line truncated
`)}function b(){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 g=new Uint8Array(32);return crypto.getRandomValues(g),n=Array.from(g,m=>m.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const h=b();function u(n,r){if(!n)return"";const g=crypto.getRandomValues(new Uint8Array(8)),m=Array.from(g,L=>L.toString(16).padStart(2,"0")).join(""),x=new TextEncoder().encode(r+m);let I="";for(let L=0;L<n.length;L++){const P=n.charCodeAt(L)^x[L%x.length]^g[L%g.length]^L*31+17&255;I+=String.fromCharCode(P)}return m+":"+btoa(I)}function y(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 m=atob(n);let x="";for(let I=0;I<m.length;I++){const L=m.charCodeAt(I)^r.charCodeAt(I%r.length);x+=String.fromCharCode(L)}return x}catch{return""}}function c(n,r,g){const m=safeGet(`${n}-${r}-${g}-enc`);return y(m,h)}function p(n,r,g,m){const x=`${n}-${r}-${g}-enc`;m?safeSet(x,u(m,h)):safeRemove(x)}function d(n,r){return c(n,r,"token")}function o(n,r){return c(n,r,"username")}function v(n,r,g){p(n,r,"token",g)}function i(n,r,g){p(n,r,"username",g)}function t(){const n={};for(const r of l())n[r]={readonly:{username:o(r,"readonly"),token:d(r,"readonly")},admin:{username:o(r,"admin"),token:d(r,"admin")}};return n}function e(){l().forEach(n=>{["readonly","admin"].forEach(r=>{["token","username"].forEach(g=>{safeRemove(`${n}-${r}-${g}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function s(n){const r=d(n,"readonly"),g=o(n,"readonly"),m=d(n,"admin"),x=o(n,"admin"),I=y(safeGet(`${n}-token-enc`),h),L=y(safeGet(`${n}-username-enc`),h);return{username:x||g||L,token:m||r||I,readonlyToken:r||I,readonlyUsername:g||L,adminToken:m||I,adminUsername:x||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{f();const n=document.getElementById("token-management-modal"),r=t();l().forEach(g=>{const m=r[g];document.getElementById(`${g}-readonly-username`).value=m.readonly.username,document.getElementById(`${g}-readonly-token`).value=m.readonly.token,document.getElementById(`${g}-admin-username`).value=m.admin.username,document.getElementById(`${g}-admin-token`).value=m.admin.token,document.getElementById(`${g}-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 g=r.dataset.target,m=document.getElementById(g);m.type==="password"?(m.type="text",r.textContent="\u{1F648}"):(m.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=l();n.forEach(m=>{i(m,"readonly",document.getElementById(`${m}-readonly-username`).value.trim()),v(m,"readonly",document.getElementById(`${m}-readonly-token`).value.trim()),i(m,"admin",document.getElementById(`${m}-admin-username`).value.trim()),v(m,"admin",document.getElementById(`${m}-admin-token`).value.trim())});const r={};let g=!1;if(n.forEach(m=>{const x={},I=document.getElementById(`${m}-readonly-username`).value.trim(),L=document.getElementById(`${m}-readonly-token`).value.trim(),P=document.getElementById(`${m}-admin-username`).value.trim(),C=document.getElementById(`${m}-admin-token`).value.trim();I&&L&&(x.readonly={username:I,password:L},g=!0),P&&C&&(x.admin={username:P,password:C},g=!0),Object.keys(x).length>0&&(r[m]=x)}),g){n.forEach(m=>{r[m]&&(document.getElementById(`${m}-token-status`).textContent="Verifying...",document.getElementById(`${m}-token-status`).className="token-status")});try{const x=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:r})})).json();x.results?n.forEach(I=>{const L=document.getElementById(`${I}-token-status`);if(!r[I]){L.textContent="";return}const P=x.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")}):x.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 "+(x.error||"Failed"),document.getElementById(`${I}-token-status`).className="token-status error")})}catch(m){console.erro Line truncated
Current: ${s.currentVersion}
New: ${s.updateVersion}
Current: ${i.currentVersion}
New: ${i.updateVersion}
`+(s.updateTitle?`${s.updateTitle}
`+(i.updateTitle?`${i.updateTitle}
`:"")+`The DNS server will restart during the update.
Proceed?`)){d.textContent=a,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const c=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(v)}`,{method:"POST"})).json();if(!c.success)throw new Error(c.error||"Update failed");if(c.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${c.newVersion}`;const t=c.downloadLink?`
Download: ${c.downloadLink}`:"",i=c.instructionsLink?`
Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} update requires manual installation. Current: ${c.previousVersion} \u2192 ${c.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${r.toUpperCase()} updated successfully! ${c.previousVersion} \u2192 ${c.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=a,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(p){console.error("DNS update error:",p),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${r.toUpperCase()}: ${p.message}`,"error"),setTimeout(()=>{d.textContent=a,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-update"]');if(!v)return;const d=v.id.replace("-update","");SITE.dnsServers[d]&&o(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`
Proceed?`)){d.textContent=o,d.disabled=!1,d.title="Update DNS server";return}d.textContent="\u{1F504}",d.title="Updating...";const s=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(p)}`,{method:"POST"})).json();if(!s.success)throw new Error(s.error||"Update failed");if(s.manualUpdateRequired){d.textContent="\u2B06\uFE0F",d.title=`Update available: ${s.newVersion}`;const n=s.downloadLink?`
Download: ${s.downloadLink}`:"",r=s.instructionsLink?`
Instructions: ${s.instructionsLink}`:"";showNotification(`${c.toUpperCase()} update requires manual installation. Current: ${s.previousVersion} \u2192 ${s.newVersion}. Please update manually on the host machine.`,"warning",8e3),d.disabled=!1;return}d.textContent="\u2705",d.title="Updated successfully!",showNotification(`${c.toUpperCase()} updated successfully! ${s.previousVersion} \u2192 ${s.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server",window.refreshAll()},1e4)}catch(v){console.error("DNS update error:",v),d.textContent="\u274C",d.title="Update failed",showNotification(`Failed to update ${c.toUpperCase()}: ${v.message}`,"error"),setTimeout(()=>{d.textContent=o,d.disabled=!1,d.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",c=>{const p=c.target.closest('[id$="-update"]');if(!p)return;const d=p.id.replace("-update","");SITE.dnsServers[d]&&a(d,SITE.dnsServers[d]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3>
@@ -366,7 +366,7 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);let g=null;function h(r){g=r;const v=SITE.dnsServers[r]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(v.name||r).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=v.ip||"",document.getElementById("dns-edit-port").value=v.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=v.name||"",d.classList.add("show")}async function f(){if(!g)return;const r=document.getElementById("dns-edit-ip").value.trim(),v=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!r){showNotification("Server IP is required","warning");return}const a={dnsServers:{}};a.dnsServers[g]={ip:r,port:String(v)},d&&(a.dnsServers[g].name=d);try{const s=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();s.success?(SITE.dnsServers[g]=a.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(s.error||"Failed to save settings","error")}catch(p){showNotification("Failed to save: "+p.message,"error")}}async function m(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const v=await(await secureFetch("/api/v1/config")).json();v.dnsServers&&delete v.dnsServers[g];const a=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:v.dnsServers||{}})})).json();if(a.success){delete SITE.dnsServers[g];const p=document.querySelector(`.top [data-app="${g}"]`);p&&p.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(a.error||"Failed to remove","error")}catch(r){showNotification("Failed to remove: "+r.message,"error")}}function b(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",m),document.getElementById("dns-settings-modal")?.addEventListener("click",r=>{r.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",r=>{const v=r.target.closest('[id$="-settings"]');if(!v)return;const d=v.id.replace("-settings","");SITE.dnsServers[d]&&(r.stopPropagation(),h(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
</div>`);let f=null;function b(c){f=c;const p=SITE.dnsServers[c]||{},d=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(p.name||c).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=p.ip||"",document.getElementById("dns-edit-port").value=p.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=p.name||"",d.classList.add("show")}async function h(){if(!f)return;const c=document.getElementById("dns-edit-ip").value.trim(),p=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,d=document.getElementById("dns-edit-name").value.trim();if(!c){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[f]={ip:c,port:String(p)},d&&(o.dnsServers[f].name=d);try{const i=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();i.success?(SITE.dnsServers[f]=o.dnsServers[f],showNotification(`${f.toUpperCase()} settings saved`,"success"),y(),window.refreshAll()):showNotification(i.error||"Failed to save settings","error")}catch(v){showNotification("Failed to save: "+v.message,"error")}}async function u(){if(f&&confirm(`Remove ${f.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const p=await(await secureFetch("/api/v1/config")).json();p.dnsServers&&delete p.dnsServers[f];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:p.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[f];const v=document.querySelector(`.top [data-app="${f}"]`);v&&v.remove(),showNotification(`${f.toUpperCase()} removed from dashboard`,"success"),y()}else showNotification(o.error||"Failed to remove","error")}catch(c){showNotification("Failed to remove: "+c.message,"error")}}function y(){closeModal("dns-settings-modal"),f=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",y),document.getElementById("dns-settings-save")?.addEventListener("click",h),document.getElementById("dns-settings-delete")?.addEventListener("click",u),document.getElementById("dns-settings-modal")?.addEventListener("click",c=>{c.target.id==="dns-settings-modal"&&y()}),document.querySelector(".top")?.addEventListener("click",c=>{const p=c.target.closest('[id$="-settings"]');if(!p)return;const d=p.id.replace("-settings","");SITE.dnsServers[d]&&(c.stopPropagation(),b(d))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header">
@@ -390,66 +390,66 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
</div>
</div>
</div>
</div>`);let l=null,o=null,g=!1,h=null,f=null,m=!1,b=null,r=null,v=!1,d=null,a=!1;async function p(k,x=25){try{const I=getDnsServerAddr(k),E=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function s(k){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[k]||"var(--fg)"}function e(k){const x=document.createElement("div");if(x.className="log-entry",x.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",k.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(k.raw)}</span>`,x;const I=s(k.rcode),E=k.rcode==="Refused"||k.rcode==="REFUSED";return x.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(k.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(k.client)}">${escapeHtml(k.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${E?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(k.domain)}">${escapeHtml(k.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(k.type)}</span>
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(k.rcode)}</span>
`,x}async function n(){if(v){await B();return}if(m){await T();return}if(g||!l)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(l,k);if(I.error){x.innerHTML=`
</div>`);let l=null,a=null,f=!1,b=null,h=null,u=!1,y=null,c=null,p=!1,d=null,o=!1;async function v(E,w=25){try{const S=getDnsServerAddr(E),T=await fetch(`/api/v1/dns/logs?server=${S}&limit=${w}`,{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 i(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 w=document.createElement("div");if(w.className="log-entry",w.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 w.style.gridTemplateColumns="1fr",w.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(E.raw)}</span>`,w;const S=i(E.rcode),T=E.rcode==="Refused"||E.rcode==="REFUSED";return w.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>
`,w}async function e(){if(p){await B();return}if(u){await I();return}if(f||!l)return;const E=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const S=await v(l,E);if(S.error){w.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>`;return}x.innerHTML=`
<div>${escapeHtml(S.error)}</div>
</div>`;return}w.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>
<span>Client</span>
<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);w.appendChild(k)}):w.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet
</div>`}catch(I){x.innerHTML=`
</div>`}catch(S){w.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function c(k){l=k,g=!1,m=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${k.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),n(),o=setInterval(n,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),o&&(clearInterval(o),o=null),y(),l=null,m=!1,h=null,f=null,v=!1,b=null,r=null,g=!1}function i(k){d&&y();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),E=document.getElementById("logs-content");o&&(clearInterval(o),o=null);try{d=new EventSource(`/api/v1/logs/stream/${k}`),a=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),d.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),y();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function s(E){l=E,f=!1,u=!1;const w=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"),w.classList.add("show"),e(),a=setInterval(e,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),a&&(clearInterval(a),a=null),g(),l=null,u=!1,b=null,h=null,p=!1,y=null,c=null,f=!1}function r(E){d&&g();const w=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),T=document.getElementById("logs-content");a&&(clearInterval(a),a=null);try{d=new EventSource(`/api/v1/logs/stream/${E}`),o=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.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}")),d.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),g();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 U=(D.stream||"stdout")==="stderr",N=U?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${U?"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;">${U?"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=P=>{console.error("EventSource error:",P),y()}}catch(S){console.error("Failed to start streaming:",S),y()}}function y(){d&&(d.close(),d=null),a=!1;const k=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");k&&(k.classList.remove("active"),k.textContent="\u{1F4E1} Live",k.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),m&&h&&!o&&(o=setInterval(T,DC.POLL.LOGS))}async function u(k,x=100){try{const I=`/api/v1/logs/container/${k}?tail=${x}&timestamps=true`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${E.status}: ${E.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=k.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return x.innerHTML=`
<div style="flex-shrink: 0;">${E}</div>
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(k.text)}</div>
`,x}async function T(){if(g||!h||!m)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await u(h,k);if(I.error){x.innerHTML=`
`,T.appendChild(O),T.scrollTop=T.scrollHeight;T.children.length>500;)T.removeChild(T.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},d.onerror=A=>{console.error("EventSource error:",A),g()}}catch(k){console.error("Failed to start streaming:",k),g()}}function g(){d&&(d.close(),d=null),o=!1;const E=document.getElementById("logs-stream"),w=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"),w&&(w.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),u&&b&&!a&&(a=setInterval(I,DC.POLL.LOGS))}async function m(E,w=100){try{const S=`/api/v1/logs/container/${E}?tail=${w}&timestamps=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 x(E){const w=document.createElement("div");w.className="log-entry",w.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 w.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>
`,w}async function I(){if(f||!b||!u)return;const E=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const S=await m(b,E);if(S.error){w.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>`;return}x.innerHTML=`
<div>${escapeHtml(S.error)}</div>
</div>`;return}w.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=x(T);w.appendChild(k)}),w.scrollTop=w.scrollHeight):w.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){w.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function L(k,x){h=k,f=x,m=!0,v=!1,g=!1,y();const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display=""),I.classList.add("show"),T(),o=setInterval(T,DC.POLL.LOGS)}async function A(k,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(k)}&tail=${x}`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function C(k){const x=document.createElement("div");x.className="log-entry",x.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=k.text;let E="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(E="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(E="DEBUG",S="var(--muted)");const D=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${E}</span>`;return x.innerHTML=`
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function L(E,w){b=E,h=w,u=!0,p=!1,f=!1,g();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} ${w} - Container Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display=""),S.classList.add("show"),I(),a=setInterval(I,DC.POLL.LOGS)}async function P(E,w=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(E)}&tail=${w}`,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 w=document.createElement("div");w.className="log-entry",w.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 w.innerHTML=`
<div style="flex-shrink: 0;">${D}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
`,x}async function B(){if(g||!b||!v)return;const k=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await A(b,k);if(I.error){x.innerHTML=`
<div style="flex: 1; color: ${k}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div>
`,w}async function B(){if(f||!y||!p)return;const E=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const S=await P(y,E);if(S.error){w.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>`;return}x.innerHTML=`
<div>${escapeHtml(S.error)}</div>
</div>`;return}w.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);w.appendChild(k)}),w.scrollTop=w.scrollHeight):w.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){w.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function $(k,x){b=k,r=x,v=!0,m=!1,g=!1;const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display="none"),I.classList.add("show"),B(),o=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",k=>{const x=k.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&c(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const k=document.getElementById("logs-pause");g?(k.textContent="\u25B6\uFE0F Resume",k.classList.add("paused")):(k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),n())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||n()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!m||!h||(a?y():i(h))}),document.getElementById("logs-modal")?.addEventListener("click",k=>{k.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",k=>{k.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=c})(),(function(){injectModal("service-edit-modal",`
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function $(E,w){y=E,c=w,p=!0,u=!1,f=!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} ${w} - Application Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),A&&(A.style.display="none"),S.classList.add("show"),B(),a=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",E=>{const w=E.target.closest('[id$="-logs"]');if(!w)return;const S=w.id.replace("-logs","");SITE.dnsServers[S]&&s(S)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{f=!f;const E=document.getElementById("logs-pause");f?(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",()=>{f||e()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!u||!b||(o?g():r(b))}),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=s})(),(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,45 +805,45 @@ Instructions: ${c.instructionsLink}`:"";showNotification(`${r.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button>
</div>
</div>
</div>`)})(),(function(){async function l(m){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(m)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const r=await b.json();if(r.success){const v=document.getElementById("existing-ca-select");return v.innerHTML="",r.cas.length===0?v.innerHTML='<option value="">No CAs found in Caddyfile</option>':(v.innerHTML='<option value="">Select existing CA...</option>',r.cas.forEach(d=>{const a=document.createElement("option");typeof d=="object"?(a.value=d.id,a.textContent=d.displayName||d.name):(a.value=d,a.textContent=d),v.appendChild(a)})),r.data.cas}else throw new Error(r.message)}catch(b){console.error("Error loading CAs:",b);const r=document.getElementById("existing-ca-select");return r.innerHTML='<option value="">Error loading CAs</option>',[]}}function o(m){const{subdomain:b,port:r,ip:v,sslType:d,caName:a,existingCa:p,enableAuth:s,enableCors:e,customHeaders:n,upstreamPath:c,healthCheck:t,timeout:i,tailscaleOnly:y}=m;let u=`${buildDomain(b)} {
`;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 l(u){try{const y=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(u)}`);if(!y.ok)throw new Error(`Failed to load CAs: ${y.status}`);const c=await y.json();if(c.success){const p=document.getElementById("existing-ca-select");return p.innerHTML="",c.cas.length===0?p.innerHTML='<option value="">No CAs found in Caddyfile</option>':(p.innerHTML='<option value="">Select existing CA...</option>',c.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),p.appendChild(o)})),c.data.cas}else throw new Error(c.message)}catch(y){console.error("Error loading CAs:",y);const c=document.getElementById("existing-ca-select");return c.innerHTML='<option value="">Error loading CAs</option>',[]}}function a(u){const{subdomain:y,port:c,ip:p,sslType:d,caName:o,existingCa:v,enableAuth:i,enableCors:t,customHeaders:e,upstreamPath:s,healthCheck:n,timeout:r,tailscaleOnly:g}=u;let m=`${buildDomain(y)} {
`;switch(g&&(m+=` @blocked not remote_ip 100.64.0.0/10
`,m+=` respond @blocked "Access denied. Tailscale connection required." 403
`),d){case"letsencrypt":break;case"caddy-managed":m+=` tls internal
`;break;case"existing-ca":v&&(m+=` tls {
ca ${v}
}
`);break;case"custom-ca":a&&(u+=` tls {
ca ${a}
`);break;case"custom-ca":o&&(m+=` tls {
ca ${o}
}
`);break}if(s&&(u+=` basicauth {
`);break}if(i&&(m+=` 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} {
`,c&&c!=="/"&&(u+=` rewrite ${c}
`),i&&i!==30&&(u+=` transport http {
`,u+=` dial_timeout ${i}s
`,u+=` response_header_timeout ${i}s
`,u+=` }
`),u+=` }
`,u+=`}
`,u}async function g(m,b,r=DC.DEFAULTS.TTL){const v=window.getToken(getPrimaryDnsId(),"admin");if(!v)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(m),a=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:b,ttl:r,token:v,server:SITE.dnsIp})});if(!a.ok){const s=await a.text();throw new Error(`DNS API Error: ${a.status} - ${s}`)}const p=await a.json();if(!p.success)throw new Error(`DNS Error: ${p.error||"Unknown error"}`);return p}async function h(m){const b={id:m.subdomain,name:m.name,logo:m.logo||`/assets/${m.subdomain}.png`};m.category&&(b.category=m.category),m.containerId&&(b.containerId=m.containerId);try{const r=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!r.ok){const v=await r.json();throw new Error(v.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(r){throw console.error("Failed to add service to config:",r),r}}async function f(m){const b=document.getElementById("service-subdomain-input").value.trim(),r=document.getElementById("service-ip-input").value.trim()||"localhost",v=document.getElementById("service-port-input").value.trim()||"80",d=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${r}:${v}`,config:m})}),a=await d.json();if(!d.ok||!a.success)throw new Error(a.error||`Caddy API Error: ${d.status}`);return a}window.loadExistingCAs=l,window.generateCaddyConfig=o,window.createDnsRecord=g,window.addServiceToConfig=h,window.addToCaddyfile=f})(),(function(){let l=null;function o(r){l=r;const v=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${r.name}`,document.getElementById("edit-service-name").value=r.name,document.getElementById("edit-service-url-display").textContent=r.url||buildServiceUrl(r.id),document.getElementById("edit-service-logo-preview").src=r.logo||`/assets/${r.id}.png`,document.getElementById("edit-subdomain").value=r.id,document.getElementById("edit-port").value=r.port||"",document.getElementById("edit-ip").value=r.ip||"localhost",document.getElementById("edit-tailscale-only").checked=r.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=r.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=r.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),v.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function h(){if(!l)return;const r=document.getElementById("edit-subdomain").value.trim().toLowerCase(),v=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),a=document.getElementById("edit-ip").value.trim()||"localhost",p=document.getElementById("edit-tailscale-only").checked,s=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!r){showNotification("Subdomain is required","warning");return}const n=l.id,c=[];if(r!==n&&c.push("subdomain"),v&&v!==l.name&&c.push("name"),d&&d!==String(l.port)&&c.push("port"),a!==l.ip&&c.push("ip"),p!==(l.tailscaleOnly||!1)&&c.push("tailscale"),s&&s!==l.logo&&c.push("logo"),e!==(l.category||"")&&c.push("category"),c.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const y=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:n,newSubdomain:r,name:v||l.name,port:d||l.port,ip:a,tailscaleOnly:p,logo:s||void 0,category:e})})).json();if(!y.success)throw new Error(y.error||"Failed to update service");const u=window.APPS.findIndex(w=>w.id===n);u!==-1&&(window.APPS[u]={...window.APPS[u],id:r,name:v||window.APPS[u].name,port:d||window.APPS[u].port,ip:a,tailscaleOnly:p,logo:s||window.APPS[u].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(i){console.error("Error saving service changes:",i),showNotification(`Error saving changes: ${i.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async r=>{const v=r.target.files[0];if(!v)return;if(!v.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async a=>{const p=a.target.result;if(document.getElementById("edit-service-logo-preview").src=p,document.getElementById("edit-logo-url").value=p,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:p})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val Line truncated
`),t&&(m+=` header {
`,m+=` Access-Control-Allow-Origin "*"
`,m+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,m+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,m+=` }
`),e)try{const x=JSON.parse(e);m+=` header {
`,Object.entries(x).forEach(([I,L])=>{m+=` ${I} "${L}"
`}),m+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(m+=` health_uri ${n}
`),m+=` reverse_proxy ${p}:${c} {
`,s&&s!=="/"&&(m+=` rewrite ${s}
`),r&&r!==30&&(m+=` transport http {
`,m+=` dial_timeout ${r}s
`,m+=` response_header_timeout ${r}s
`,m+=` }
`),m+=` }
`,m+=`}
`,m}async function f(u,y,c=DC.DEFAULTS.TTL){const p=window.getToken(getPrimaryDnsId(),"admin");if(!p)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const d=buildDomain(u),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:d,ip:y,ttl:c,token:p,server:SITE.dnsIp})});if(!o.ok){const i=await o.text();throw new Error(`DNS API Error: ${o.status} - ${i}`)}const v=await o.json();if(!v.success)throw new Error(`DNS Error: ${v.error||"Unknown error"}`);return v}async function b(u){const y={id:u.subdomain,name:u.name,logo:u.logo||`/assets/${u.subdomain}.png`};u.category&&(y.category=u.category),u.containerId&&(y.containerId=u.containerId);try{const c=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(y)});if(!c.ok){const p=await c.json();throw new Error(p.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),y}catch(c){throw console.error("Failed to add service to config:",c),c}}async function h(u){const y=document.getElementById("service-subdomain-input").value.trim(),c=document.getElementById("service-ip-input").value.trim()||"localhost",p=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(y),upstream:`${c}:${p}`,config:u})}),o=await d.json();if(!d.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${d.status}`);return o}window.loadExistingCAs=l,window.generateCaddyConfig=a,window.createDnsRecord=f,window.addServiceToConfig=b,window.addToCaddyfile=h})(),(function(){let l=null;function a(c){l=c;const p=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${c.name}`,document.getElementById("edit-service-name").value=c.name,document.getElementById("edit-service-url-display").textContent=c.url||buildServiceUrl(c.id),document.getElementById("edit-service-logo-preview").src=c.logo||`/assets/${c.id}.png`,document.getElementById("edit-subdomain").value=c.id,document.getElementById("edit-port").value=c.port||"",document.getElementById("edit-ip").value=c.ip||"localhost",document.getElementById("edit-tailscale-only").checked=c.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=c.logo||"";const d=document.getElementById("edit-service-category");d&&(d.dataset.current=c.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),p.classList.add("show")}function f(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const c=document.getElementById("edit-subdomain").value.trim().toLowerCase(),p=document.getElementById("edit-service-name").value.trim(),d=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",v=document.getElementById("edit-tailscale-only").checked,i=document.getElementById("edit-logo-url").value.trim(),t=document.getElementById("edit-service-category")?.value||"";if(!c){showNotification("Subdomain is required","warning");return}const e=l.id,s=[];if(c!==e&&s.push("subdomain"),p&&p!==l.name&&s.push("name"),d&&d!==String(l.port)&&s.push("port"),o!==l.ip&&s.push("ip"),v!==(l.tailscaleOnly||!1)&&s.push("tailscale"),i&&i!==l.logo&&s.push("logo"),t!==(l.category||"")&&s.push("category"),s.length===0){f();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const g=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:c,name:p||l.name,port:d||l.port,ip:o,tailscaleOnly:v,logo:i||void 0,category:t})})).json();if(!g.success)throw new Error(g.error||"Failed to update service");const m=window.APPS.findIndex(x=>x.id===e);m!==-1&&(window.APPS[m]={...window.APPS[m],id:c,name:p||window.APPS[m].name,port:d||window.APPS[m].port,ip:o,tailscaleOnly:v,logo:i||window.APPS[m].logo,category:t||void 0}),f(),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 c=>{const p=c.target.files[0];if(!p)return;if(!p.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const d=new FileReader;d.onload=async o=>{const v=o.target.result;if(document.getElementById("edit-service-logo-preview").src=v,document.getElementById("edit-logo-url").value=v,l)try{const t=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:v})})).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{a&&(a.textContent="\u{1F504}",a.disabled=!0,a.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${r}/update`,{method:"POST"})).json();if(e.success){const n=window.APPS.find(c=>c.id===d);n&&e.newContainerId&&(n.containerId=e.newContainerId),a&&(a.textContent="\u2705",a.title="Updated successfully!",setTimeout(()=>{a.textContent=p,a.disabled=!1,a.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${v} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(s){console.error("Update error:",s),a&&(a.textContent="\u274C",a.title="Update failed",setTimeout(()=>{a.textContent=p,a.disabled=!1,a.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${v}: ${s.message}`,"error")}}async function b(r,v){const d=window.APPS.find(u=>u.id===r),a=d?buildDomain(d.id):null,p=d?.containerId,s=await f(v||r,p,d?.containerId);if(s===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(s&&p)try{const u=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${u.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(u){console.error("App removal error:",u)}else if(s&&a){try{const u=d?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(a)}&type=A&ipAddress=${encodeURIComponent(u)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(u){e.dns=u.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(u){e.caddy=u.message}}const n=window.APPS.findIndex(u=>u.id===r);n>-1&&(window.APPS.splice(n,1),e.dashboard=!0);try{const u=safeGetJSON("custom-apps",[]),w=u.findIndex(T=>T.id===r);w>-1&&(u.splice(w,1),safeSet("custom-apps",JSON.stringify(u)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(u){e.service=u.message}window.buildGrid(),window.refreshAll();let c=!1,t=[];e.dashboard||(c=!0,t.push("\u2717 Failed to remove from dashboard"));const i=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=u=>!u||i.some(w=>u.toLowerCase().includes(w.toLowerCase()));e.container&&!y(e.container)&&(c=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(c=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(c=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(c=!0,t.push(`\u26A0 Service File: ${e.service}`)),c&&showNotification(`Error deleting "${v||r}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=o,window.showDeleteModal=f,window.updateContainer=m,window.deleteService=b})(),(function(){function l(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function o(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",n=document.getElementById("service-ip-input").value||h.lan||"localhost",c=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,i=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,u=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${n}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const k={subdomain:e,port:c,ip:n,sslType:t,caName:i,existingCa:y,enableAuth:u,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:A,timeout:C},x=window.generateCaddyConfig(k),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const h={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const i=await t.json();i.lan&&(h.lan=i.lan),i.tailscale&&(h.tailscale=i.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),n=document.getElementById("quick-ip-tailscale");e&&(h.lan?(e.dataset.ip=h.lan,e.textContent=`LAN (${h.lan})`,e.title=`LAN IP: ${h.lan}`):e Line truncated
The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const t=await(await secureFetch(`/api/v1/containers/${c}/update`,{method:"POST"})).json();if(t.success){const e=window.APPS.find(s=>s.id===d);e&&t.newContainerId&&(e.containerId=t.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=v,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${p} updated successfully!`,"success")}else throw new Error(t.error||"Update failed")}catch(i){console.error("Update error:",i),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=v,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${p}: ${i.message}`,"error")}}async function y(c,p){const d=window.APPS.find(m=>m.id===c),o=d?buildDomain(d.id):null,v=d?.containerId,i=await h(p||c,v,d?.containerId);if(i===null)return;let t={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(i&&v)try{const m=new URLSearchParams({containerId:d.containerId,subdomain:d.id,ip:d.ip||"localhost",deleteContainer:"true"}),I=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(d.id)}?${m.toString()}`,{method:"DELETE"})).json();I.success?t={...t,...I.results,dashboard:!1}:console.error("App removal failed:",I.error)}catch(m){console.error("App removal error:",m)}else if(i&&o){try{const m=d?.ip||"localhost",I=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(m)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();t.dns=I.success?"deleted":I.error||"failed"}catch(m){t.dns=m.message}try{const x=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();t.caddy=x.success||x.error&&x.error.includes("not found")?"removed":x.error||"failed"}catch(m){t.caddy=m.message}}const e=window.APPS.findIndex(m=>m.id===c);e>-1&&(window.APPS.splice(e,1),t.dashboard=!0);try{const m=safeGetJSON("custom-apps",[]),x=m.findIndex(I=>I.id===c);x>-1&&(m.splice(x,1),safeSet("custom-apps",JSON.stringify(m)))}catch{}try{const x=await(await secureFetch(`/api/v1/services/${encodeURIComponent(c)}`,{method:"DELETE"})).json();t.service=x.success?"removed":x.error||"failed"}catch(m){t.service=m.message}window.buildGrid(),window.refreshAll();let s=!1,n=[];t.dashboard||(s=!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"],g=m=>!m||r.some(x=>m.toLowerCase().includes(x.toLowerCase()));t.container&&!g(t.container)&&(s=!0,n.push(`\u26A0 Container: ${t.container}`)),t.dns&&!g(t.dns)&&(s=!0,n.push(`\u26A0 DNS Record: ${t.dns}`)),t.caddy&&!g(t.caddy)&&(s=!0,n.push(`\u26A0 Caddy Config: ${t.caddy}`)),t.service&&!g(t.service)&&(s=!0,n.push(`\u26A0 Service File: ${t.service}`)),s&&showNotification(`Error deleting "${p||c}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=a,window.showDeleteModal=h,window.updateContainer=u,window.deleteService=y})(),(function(){function l(t){return t.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 t=document.getElementById("service-subdomain-input").value||"subdomain",e=document.getElementById("service-ip-input").value||b.lan||"localhost",s=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",g=document.getElementById("existing-ca-select").value,m=document.getElementById("enable-auth").checked,x=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:s,ip:e,sslType:n,caName:r,existingCa:g,enableAuth:m,enableCors:x,customHeaders:I,upstreamPath:L,healthCheck:P,timeout:C},w=window.generateCaddyConfig(E),S=document.getElementById("caddy-config-preview");S&&(S.value=w)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function h(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const r=await n.json();r.lan&&(b.lan=r.lan),r.tailscale&&(b.tailscale=r.tailscale)}}catch{}const t=document.getElementById("quick-ip-lan"),e=document.getElementById("quick-ip-tailscale");t&&(b.lan?(t.dataset.ip=b.lan,t.textContent=`LAN (${b.lan})`,t.title=`LAN IP: ${b.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,g()}function r(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),c=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),i=document.getElementById("tab-local"),y=document.getElementById("tab-external");function u(){e.checked?(c.style.display="grid",t.style.display="none",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(c.style.display="none",t.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function v(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),c=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&n&&(n.value=L),c&&(c.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),n?.addEventListener("input",()=>{t=n.value!==l(e?.value||"");const L=n.value.trim()||l(e?.value||"");c&&(c.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const i=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;i?.addEventListener("input",()=>{const L=l(i.value);!T&&y&&(y.value=L);const A=y?.value||L;u&&(u.textContent=A?`\u2192 ${buildDomain(A)}`:""),w&&(w.textContent=A?buildDomain(A):"")}),y?.addEventListener("input",()=>{T=y.value!==l(i?.value||"");const L=y.value.trim()||l(i?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function d(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),c=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),i=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,A=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!c){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(c);try{const B={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const P=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=P.success?"created":P.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const E={subdomain:c,externalUrl:n,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},P=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();B.caddy=P.success?"created":P.error||"failed"}catch(E){B.caddy=E.message}const $={id:c,name:e,url:`https://${C}`,externalUrl:n,logo:t||i||"\u{1F310}",isExternal:!0,isCustom:!0};A&&($.category=A),window.APPS.push($),B.dashboard=!0;const k=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(E=>!k.includes(E.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(E){console.warn("Failed to save to services.json:",E)}window.buildGrid(),window.refreshAll(),a();const I=[`External service "${e}" added!`];y&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI Line truncated
<span style="color: var(--muted); margin-left: 6px;">${x.self?.hostname} (${x.self?.ip})</span>
`,g.disabled=!1):x.installed?(r.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',g.disabled=!0):(r.innerHTML='<span style="color: var(--muted);">Not available</span>',g.disabled=!0)}catch{r.innerHTML='<span style="color: var(--muted);">Could not check</span>',g.disabled=!0}g.checked=!1,f()}function c(){const t=document.getElementById("service-type-local"),e=document.getElementById("service-type-external"),s=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),r=document.getElementById("tab-local"),g=document.getElementById("tab-external");function m(){t.checked?(s.style.display="grid",n.style.display="none",r&&(r.style.background="var(--accent)",r.style.color="var(--bg)"),g&&(g.style.background="transparent",g.style.color="var(--muted)")):(s.style.display="none",n.style.display="block",g&&(g.style.background="var(--accent)",g.style.color="var(--bg)"),r&&(r.style.background="transparent",r.style.color="var(--muted)"))}t?.addEventListener("change",m),e?.addEventListener("change",m)}function p(){const t=document.getElementById("service-name-input"),e=document.getElementById("service-subdomain-input"),s=document.getElementById("subdomain-preview");let n=!1;t?.addEventListener("input",()=>{const L=l(t.value);!n&&e&&(e.value=L),s&&(s.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()}),e?.addEventListener("input",()=>{n=e.value!==l(t?.value||"");const L=e.value.trim()||l(t?.value||"");s&&(s.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()});const r=document.getElementById("external-service-name"),g=document.getElementById("external-service-subdomain"),m=document.getElementById("external-subdomain-preview"),x=document.getElementById("external-domain-preview");let I=!1;r?.addEventListener("input",()=>{const L=l(r.value);!I&&g&&(g.value=L);const P=g?.value||L;m&&(m.textContent=P?`\u2192 ${buildDomain(P)}`:""),x&&(x.textContent=P?buildDomain(P):"")}),g?.addEventListener("input",()=>{I=g.value!==l(r?.value||"");const L=g.value.trim()||l(r?.value||"");m&&(m.textContent=L?`\u2192 ${buildDomain(L)}`:""),x&&(x.textContent=L?buildDomain(L):"")})}async function d(){const t=document.getElementById("external-service-name").value.trim(),e=document.getElementById("external-service-url").value.trim(),s=(document.getElementById("external-service-subdomain").value.trim()||l(t)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),r=document.getElementById("external-service-icon").value.trim(),g=document.getElementById("external-create-dns").checked,m=document.getElementById("external-create-caddy").checked,x=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(!s){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(s);try{const B={dns:null,caddy:null,dashboard:!1};if(g)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:x,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(m)try{const T={subdomain:s,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:s,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"],w=window.APPS.filter(T=>!E.includes(T.id));safeSet("custom-services",JSON.stringify(w));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!`];g&&S.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),m&&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
+29 -20
View File
@@ -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">
@@ -245,9 +245,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 +267,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 +290,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 +308,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 +323,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 +345,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>
@@ -760,14 +760,23 @@
var versionInfoClose = document.getElementById('version-info-close');
var latestUpdateCheck = null;
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function(character) {
return {
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
}[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) {
+19 -3
View File
@@ -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);
}
+7 -4
View File
@@ -147,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;
+29 -18
View File
@@ -50,11 +50,6 @@
// reqId is the monotonic token incremented by the caller (setLanguage/init).
// If not provided (direct API call), allocate one for backward compatibility.
if (reqId === undefined) reqId = ++_langRequestId;
if (lang === DEFAULT_LANG) {
translations = {}; // English is the default — no translation needed
loaded = true;
return;
}
try {
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
// Guard against out-of-order resolution: if another loadTranslations
@@ -80,11 +75,15 @@
}
function t(key) {
if (currentLang === DEFAULT_LANG) return key;
// If translations didn't load, fall back to the English 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;
@@ -97,8 +96,9 @@
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
loadTranslations(lang, reqId).then(() => {
// Only apply if this is still the latest request.
if (reqId === _langRequestId) applyTranslations();
// 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();
});
}
@@ -113,12 +113,25 @@
// leaving the previous language's translated text visible.
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
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');
el.placeholder = t(key);
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 => {
@@ -226,12 +239,10 @@
function start() {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) {
const reqId = ++_langRequestId;
loadTranslations(currentLang, reqId).then(() => {
if (reqId === _langRequestId) applyTranslations();
});
}
const reqId = ++_langRequestId;
loadTranslations(currentLang, reqId).then(() => {
if (reqId === _langRequestId && loaded) applyTranslations();
});
}
if (document.readyState === 'loading') {
@@ -242,7 +253,7 @@
}
// Expose globally
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
window.DCI18n = { t, setLanguage, getLanguage, isLoaded, applyTranslations, loadTranslations };
// Auto-init
init();
+8 -3
View File
@@ -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');
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
const CACHE = 'dashcaddy-shell-4a75cb88af';
const PRECACHE = [
'/',
'/index.html',
+208
View File
@@ -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, /&lt;img/);
assert.match(row, /&quot;globalThis\.pwned=1&quot;/);
});
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="🔍 "/);
});