[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
2026-08-15 00:01:17 -07:00
parent bd40fb1c17
commit ef685e515e
11 changed files with 495 additions and 167 deletions
@@ -24,13 +24,29 @@ describe('DC-077: i18n Routes', () => {
expect(res.body.default).toBe('en'); 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 app = createI18nApp();
const res = await request(app).get('/api/v1/i18n/languages'); const res = await request(app).get('/api/v1/i18n/languages');
const arabic = res.body.languages.find(l => l.code === 'ar'); const rtl = (code) => {
expect(arabic).toBeTruthy(); const entry = res.body.languages.find(l => l.code === code);
expect(arabic.rtl).toBe(true); 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 () => { 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() { module.exports = function() {
const router = express.Router(); 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 // GET /api/v1/i18n/languages — list supported languages
router.get('/i18n/languages', (req, res) => { router.get('/i18n/languages', (req, res) => {
ok(res, { ok(res, {
languages: i18n.getSupportedLanguages().map(code => ({ languages: i18n.getSupportedLanguages().map(code => ({
code, code,
name: { name: NAMES[code] || code,
en: 'English', rtl: RTL.has(code),
es: 'Español',
fr: 'Français',
de: 'Deutsch',
ar: 'العربية',
}[code] || code,
rtl: code === 'ar',
})), })),
default: i18n.DEFAULT_LANGUAGE, 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.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.invalid_input': 'Invalid input', 'error.docker_unreachable': 'Docker daemon is not reachable',
'error.disk_full': 'Disk space is critically low', '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: { // 🇸🇦 العربية ar: { // 🇸🇦 العربية
'dashboard.title': 'لوحة التحكم', 'dashboard.services': 'الخدمات', 'dashboard.containers': 'الحاويات', 'dashboard.title': 'لوحة التحكم', 'dashboard.services': 'الخدمات', 'dashboard.containers': 'الحاويات',
@@ -40,6 +42,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'الحاوية غير موجودة', 'error.service_not_found': 'الخدمة غير موجودة', 'error.container_not_found': 'الحاوية غير موجودة', 'error.service_not_found': 'الخدمة غير موجودة',
'error.invalid_input': 'إدخال غير صالح', 'error.docker_unreachable': 'لا يمكن الوصول إلى Docker', 'error.invalid_input': 'إدخال غير صالح', 'error.docker_unreachable': 'لا يمكن الوصول إلى Docker',
'error.disk_full': 'مساحة القرص منخفضة بشكل حرج', '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: { // 🇧🇩 বাংলা bn: { // 🇧🇩 বাংলা
'dashboard.title': 'ড্যাশবোর্ড', 'dashboard.services': 'পরিষেবা', 'dashboard.containers': 'কন্টেইনার', 'dashboard.title': 'ড্যাশবোর্ড', 'dashboard.services': 'পরিষেবা', 'dashboard.containers': 'কন্টেইনার',
@@ -55,6 +59,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'কন্টেইনার পাওয়া যায়নি', 'error.service_not_found': 'পরিষেবা পাওয়া যায়নি', 'error.container_not_found': 'কন্টেইনার পাওয়া যায়নি', 'error.service_not_found': 'পরিষেবা পাওয়া যায়নি',
'error.invalid_input': 'অবৈধ ইনপুট', 'error.docker_unreachable': 'Docker ডেমনে পৌঁছানো যাচ্ছে না', 'error.invalid_input': 'অবৈধ ইনপুট', 'error.docker_unreachable': 'Docker ডেমনে পৌঁছানো যাচ্ছে না',
'error.disk_full': 'ডিস্ক স্থান সংকটজনকভাবে কম', '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 cs: { // 🇨🇿 Čeština
'dashboard.title': 'Nástěnka', 'dashboard.services': 'Služby', 'dashboard.containers': 'Kontejnery', '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.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.invalid_input': 'Neplatný vstup', 'error.docker_unreachable': 'Docker daemon není dostupný',
'error.disk_full': 'Místo na disku je kriticky nízké', '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 da: { // 🇩🇰 Dansk
'dashboard.title': 'Instrumentbræt', 'dashboard.services': 'Tjenester', 'dashboard.containers': 'Containere', '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.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.invalid_input': 'Ugyldigt input', 'error.docker_unreachable': 'Docker-daemon er ikke tilgængelig',
'error.disk_full': 'Diskpladsen er kritisk lav', '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 de: { // 🇩🇪 Deutsch
'dashboard.title': 'Dashboard', 'dashboard.services': 'Dienste', 'dashboard.containers': 'Container', '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.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.invalid_input': 'Ungültige Eingabe', 'error.docker_unreachable': 'Docker-Daemon ist nicht erreichbar',
'error.disk_full': 'Speicherplatz kritisch niedrig', '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: { // 🇬🇷 Ελληνικά el: { // 🇬🇷 Ελληνικά
'dashboard.title': 'Πίνακας ελέγχου', 'dashboard.services': 'Υπηρεσίες', 'dashboard.containers': 'Κοντέινερ', 'dashboard.title': 'Πίνακας ελέγχου', 'dashboard.services': 'Υπηρεσίες', 'dashboard.containers': 'Κοντέινερ',
@@ -115,6 +127,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'Το κοντέινερ δεν βρέθηκε', 'error.service_not_found': 'Η υπηρεσία δεν βρέθηκε', 'error.container_not_found': 'Το κοντέινερ δεν βρέθηκε', 'error.service_not_found': 'Η υπηρεσία δεν βρέθηκε',
'error.invalid_input': 'Μη έγκυρη είσοδος', 'error.docker_unreachable': 'Ο δαίμονας Docker δεν είναι προσβάσιμος', 'error.invalid_input': 'Μη έγκυρη είσοδος', 'error.docker_unreachable': 'Ο δαίμονας Docker δεν είναι προσβάσιμος',
'error.disk_full': 'Ο χώρος δίσκου είναι κρίσιμα χαμηλός', '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 es: { // 🇪🇸 Español
'dashboard.title': 'Panel de control', 'dashboard.services': 'Servicios', 'dashboard.containers': 'Contenedores', '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.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.invalid_input': 'Entrada inválida', 'error.docker_unreachable': 'El demonio de Docker no es accesible',
'error.disk_full': 'Espacio en disco críticamente bajo', '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: { // 🇮🇷 فارسی fa: { // 🇮🇷 فارسی
'dashboard.title': 'داشبورد', 'dashboard.services': 'سرویس‌ها', 'dashboard.containers': 'کانتینرها', 'dashboard.title': 'داشبورد', 'dashboard.services': 'سرویس‌ها', 'dashboard.containers': 'کانتینرها',
@@ -145,6 +161,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'کانتینر یافت نشد', 'error.service_not_found': 'سرویس یافت نشد', 'error.container_not_found': 'کانتینر یافت نشد', 'error.service_not_found': 'سرویس یافت نشد',
'error.invalid_input': 'ورودی نامعتبر', 'error.docker_unreachable': 'دسترسی به Docker daemon ممکن نیست', 'error.invalid_input': 'ورودی نامعتبر', 'error.docker_unreachable': 'دسترسی به Docker daemon ممکن نیست',
'error.disk_full': 'فضای دیسک به‌طور بحرانی کم است', '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 fi: { // 🇫🇮 Suomi
'dashboard.title': 'Ohjauspaneeli', 'dashboard.services': 'Palvelut', 'dashboard.containers': 'Kontainerit', '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.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.invalid_input': 'Virheellinen syöte', 'error.docker_unreachable': 'Docker-daemoniin ei saada yhteyttä',
'error.disk_full': 'Levytila on kriittisesti vähissä', '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 fr: { // 🇫🇷 Français
'dashboard.title': 'Tableau de bord', 'dashboard.services': 'Services', 'dashboard.containers': 'Conteneurs', '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.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.invalid_input': 'Entrée invalide', 'error.docker_unreachable': 'Le démon Docker est injoignable',
'error.disk_full': 'Espace disque critique', '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: { // 🇮🇳 हिन्दी hi: { // 🇮🇳 हिन्दी
'dashboard.title': 'डैशबोर्ड', 'dashboard.services': 'सेवाएं', 'dashboard.containers': 'कंटेनर', 'dashboard.title': 'डैशबोर्ड', 'dashboard.services': 'सेवाएं', 'dashboard.containers': 'कंटेनर',
@@ -190,6 +212,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'कंटेनर नहीं मिला', 'error.service_not_found': 'सेवा नहीं मिली', 'error.container_not_found': 'कंटेनर नहीं मिला', 'error.service_not_found': 'सेवा नहीं मिली',
'error.invalid_input': 'अमान्य इनपुट', 'error.docker_unreachable': 'Docker डेमन तक नहीं पहुंच सकते', 'error.invalid_input': 'अमान्य इनपुट', 'error.docker_unreachable': 'Docker डेमन तक नहीं पहुंच सकते',
'error.disk_full': 'डिस्क स्थान गंभीर रूप से कम है', '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 hu: { // 🇭🇺 Magyar
'dashboard.title': 'Vezérlőpult', 'dashboard.services': 'Szolgáltatások', 'dashboard.containers': 'Konténerek', '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.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.invalid_input': 'Érvénytelen bemenet', 'error.docker_unreachable': 'A Docker démon nem érhető el',
'error.disk_full': 'A lemezterület kritikusan alacsony', '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 id: { // 🇮🇩 Indonesia
'dashboard.title': 'Dasbor', 'dashboard.services': 'Layanan', 'dashboard.containers': 'Kontainer', '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.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.invalid_input': 'Input tidak valid', 'error.docker_unreachable': 'Daemon Docker tidak dapat dijangkau',
'error.disk_full': 'Ruang disk sangat rendah', '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 it: { // 🇮🇹 Italiano
'dashboard.title': 'Cruscotto', 'dashboard.services': 'Servizi', 'dashboard.containers': 'Contenitori', '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.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.invalid_input': 'Input non valido', 'error.docker_unreachable': 'Il daemon Docker non è raggiungibile',
'error.disk_full': 'Spazio su disco criticamente basso', '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: { // 🇯🇵 日本語 ja: { // 🇯🇵 日本語
'dashboard.title': 'ダッシュボード', 'dashboard.services': 'サービス', 'dashboard.containers': 'コンテナ', 'dashboard.title': 'ダッシュボード', 'dashboard.services': 'サービス', 'dashboard.containers': 'コンテナ',
@@ -250,6 +280,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'コンテナが見つかりません', 'error.service_not_found': 'サービスが見つかりません', 'error.container_not_found': 'コンテナが見つかりません', 'error.service_not_found': 'サービスが見つかりません',
'error.invalid_input': '無効な入力', 'error.docker_unreachable': 'Dockerデーモンに接続できません', 'error.invalid_input': '無効な入力', 'error.docker_unreachable': 'Dockerデーモンに接続できません',
'error.disk_full': 'ディスク容量が致命的に不足しています', '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: { // 🇰🇷 한국어 ko: { // 🇰🇷 한국어
'dashboard.title': '대시보드', 'dashboard.services': '서비스', 'dashboard.containers': '컨테이너', 'dashboard.title': '대시보드', 'dashboard.services': '서비스', 'dashboard.containers': '컨테이너',
@@ -265,6 +297,8 @@ const TRANSLATIONS = {
'error.container_not_found': '컨테이너를 찾을 수 없습니다', 'error.service_not_found': '서비스를 찾을 수 없습니다', 'error.container_not_found': '컨테이너를 찾을 수 없습니다', 'error.service_not_found': '서비스를 찾을 수 없습니다',
'error.invalid_input': '잘못된 입력', 'error.docker_unreachable': 'Docker 데몬에 연결할 수 없습니다', 'error.invalid_input': '잘못된 입력', 'error.docker_unreachable': 'Docker 데몬에 연결할 수 없습니다',
'error.disk_full': '디스크 공간이 심각하게 부족합니다', '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 ms: { // 🇲🇾 Melayu
'dashboard.title': 'Papan pemuka', 'dashboard.services': 'Perkhidmatan', 'dashboard.containers': 'Bekas', '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.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.invalid_input': 'Input tidak sah', 'error.docker_unreachable': 'Docker daemon tidak dapat dijangkau',
'error.disk_full': 'Ruang cakera sangat kritikal', '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 nl: { // 🇳🇱 Nederlands
'dashboard.title': 'Dashboard', 'dashboard.services': 'Diensten', 'dashboard.containers': 'Containers', '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.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.invalid_input': 'Ongeldige invoer', 'error.docker_unreachable': 'Docker-daemon is niet bereikbaar',
'error.disk_full': 'Schijfruimte kritiek laag', '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 no: { // 🇳🇴 Norsk
'dashboard.title': 'Kontrollpanel', 'dashboard.services': 'Tjenester', 'dashboard.containers': 'Beholdere', '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.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.invalid_input': 'Ugyldig inndata', 'error.docker_unreachable': 'Docker-daemon er ikke tilgjengelig',
'error.disk_full': 'Diskplassen er kritisk lav', '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 pl: { // 🇵🇱 Polski
'dashboard.title': 'Panel', 'dashboard.services': 'Usługi', 'dashboard.containers': 'Kontenery', '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.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.invalid_input': 'Nieprawidłowe dane wejściowe', 'error.docker_unreachable': 'Daemon Docker jest niedostępny',
'error.disk_full': 'Krytycznie mało miejsca na dysku', '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 pt: { // 🇵🇹 Português
'dashboard.title': 'Painel', 'dashboard.services': 'Serviços', 'dashboard.containers': 'Contêineres', '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.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.invalid_input': 'Entrada inválida', 'error.docker_unreachable': 'Daemon do Docker inacessível',
'error.disk_full': 'Espaço em disco criticamente baixo', '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ă ro: { // 🇷🇴 Română
'dashboard.title': 'Tablou de bord', 'dashboard.services': 'Servicii', 'dashboard.containers': 'Containere', '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.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.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', '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: { // 🇷🇺 Русский ru: { // 🇷🇺 Русский
'dashboard.title': 'Панель управления', 'dashboard.services': 'Сервисы', 'dashboard.containers': 'Контейнеры', 'dashboard.title': 'Панель управления', 'dashboard.services': 'Сервисы', 'dashboard.containers': 'Контейнеры',
@@ -370,6 +416,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'Контейнер не найден', 'error.service_not_found': 'Сервис не найден', 'error.container_not_found': 'Контейнер не найден', 'error.service_not_found': 'Сервис не найден',
'error.invalid_input': 'Неверный ввод', 'error.docker_unreachable': 'Docker недоступен', 'error.invalid_input': 'Неверный ввод', 'error.docker_unreachable': 'Docker недоступен',
'error.disk_full': 'Критически мало места на диске', '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 sv: { // 🇸🇪 Svenska
'dashboard.title': 'Instrumentpanel', 'dashboard.services': 'Tjänster', 'dashboard.containers': 'Behållare', '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.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.invalid_input': 'Ogiltig inmatning', 'error.docker_unreachable': 'Docker-daemon kan inte nås',
'error.disk_full': 'Diskutrymmet är kritiskt lågt', '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: { // 🇹🇭 ไทย th: { // 🇹🇭 ไทย
'dashboard.title': 'แดชบอร์ด', 'dashboard.services': 'บริการ', 'dashboard.containers': 'คอนเทนเนอร์', 'dashboard.title': 'แดชบอร์ด', 'dashboard.services': 'บริการ', 'dashboard.containers': 'คอนเทนเนอร์',
@@ -400,6 +450,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'ไม่พบคอนเทนเนอร์', 'error.service_not_found': 'ไม่พบบริการ', 'error.container_not_found': 'ไม่พบคอนเทนเนอร์', 'error.service_not_found': 'ไม่พบบริการ',
'error.invalid_input': 'อินพุตไม่ถูกต้อง', 'error.docker_unreachable': 'ไม่สามารถเข้าถึง Docker daemon ได้', 'error.invalid_input': 'อินพุตไม่ถูกต้อง', 'error.docker_unreachable': 'ไม่สามารถเข้าถึง Docker daemon ได้',
'error.disk_full': 'พื้นที่ดิสก์เหลือน้อยวิกฤต', '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 tr: { // 🇹🇷 Türkçe
'dashboard.title': 'Kontrol Paneli', 'dashboard.services': 'Hizmetler', 'dashboard.containers': 'Konteynerler', '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.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.invalid_input': 'Geçersiz giriş', 'error.docker_unreachable': 'Docker daemonuna ulaşılamıyor',
'error.disk_full': 'Disk alanı kritik düzeyde düşük', '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: { // 🇺🇦 Українська uk: { // 🇺🇦 Українська
'dashboard.title': 'Панель керування', 'dashboard.services': 'Сервіси', 'dashboard.containers': 'Контейнери', 'dashboard.title': 'Панель керування', 'dashboard.services': 'Сервіси', 'dashboard.containers': 'Контейнери',
@@ -430,6 +484,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'Контейнер не знайдено', 'error.service_not_found': 'Сервіс не знайдено', 'error.container_not_found': 'Контейнер не знайдено', 'error.service_not_found': 'Сервіс не знайдено',
'error.invalid_input': 'Невірне введення', 'error.docker_unreachable': 'Docker недоступний', 'error.invalid_input': 'Невірне введення', 'error.docker_unreachable': 'Docker недоступний',
'error.disk_full': 'Критично мало місця на диску', '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: { // 🇵🇰 اردو ur: { // 🇵🇰 اردو
'dashboard.title': 'ڈیش بورڈ', 'dashboard.services': 'خدمات', 'dashboard.containers': 'کنٹینرز', 'dashboard.title': 'ڈیش بورڈ', 'dashboard.services': 'خدمات', 'dashboard.containers': 'کنٹینرز',
@@ -445,6 +501,8 @@ const TRANSLATIONS = {
'error.container_not_found': 'کنٹینر نہیں ملا', 'error.service_not_found': 'سروس نہیں ملی', 'error.container_not_found': 'کنٹینر نہیں ملا', 'error.service_not_found': 'سروس نہیں ملی',
'error.invalid_input': 'غلط ان پٹ', 'error.docker_unreachable': 'Docker ڈیمن تک رسائی نہیں', 'error.invalid_input': 'غلط ان پٹ', 'error.docker_unreachable': 'Docker ڈیمن تک رسائی نہیں',
'error.disk_full': 'ڈسک کی جگہ نہایت کم ہے', '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 vi: { // 🇻🇳 Tiếng Việt
'dashboard.title': 'Bảng điều khiển', 'dashboard.services': 'Dịch vụ', 'dashboard.containers': 'Bộ chứa', '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.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.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', '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: { // 🇨🇳 中文 zh: { // 🇨🇳 中文
'dashboard.title': '仪表盘', 'dashboard.services': '服务', 'dashboard.containers': '容器', 'dashboard.title': '仪表盘', 'dashboard.services': '服务', 'dashboard.containers': '容器',
@@ -475,6 +535,8 @@ const TRANSLATIONS = {
'error.container_not_found': '未找到容器', 'error.service_not_found': '未找到服务', 'error.container_not_found': '未找到容器', 'error.service_not_found': '未找到服务',
'error.invalid_input': '输入无效', 'error.docker_unreachable': '无法连接 Docker 守护进程', 'error.invalid_input': '输入无效', 'error.docker_unreachable': '无法连接 Docker 守护进程',
'error.disk_full': '磁盘空间严重不足', '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
File diff suppressed because one or more lines are too long
+29 -20
View File
@@ -8,7 +8,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <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" href="/assets/dashcaddy-favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png"> <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"/> <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> </svg>
</div> </div>
<span class="name">Internet</span> <span class="name" data-i18n="card.internet">Internet</span>
<span class="spacer"></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>
<div class="response-row"> <div class="response-row">
<span id="internet-time" class="response-time">--</span> <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"/> <line x1="12" y1="16.5" x2="12" y2="18" stroke="#0b0f1a" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>
</div> </div>
<span class="name">Auth</span> <span class="name" data-i18n="card.auth">Auth</span>
<span class="spacer"></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>
<div class="response-row"> <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>
<div class="btn-row"> <div class="btn-row">
<button id="auth-settings-btn">Settings</button> <button id="auth-settings-btn" data-i18n="action.settings">Settings</button>
</div> </div>
</div> </div>
@@ -290,7 +290,7 @@
<path d="M12 13v4M9 19h6" stroke="#7D8FE3" stroke-width="2" stroke-linecap="round"/> <path d="M12 13v4M9 19h6" stroke="#7D8FE3" stroke-width="2" stroke-linecap="round"/>
</svg> </svg>
</div> </div>
<span class="name">Tailscale</span> <span class="name" data-i18n="card.tailscale">Tailscale</span>
<span class="spacer"></span> <span class="spacer"></span>
<span id="tailscale-pill" class="badge off"></span> <span id="tailscale-pill" class="badge off"></span>
</div> </div>
@@ -308,9 +308,9 @@
<div class="logo-wrap"> <div class="logo-wrap">
<span style="font-size: 28px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">🔐</span> <span style="font-size: 28px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">🔐</span>
</div> </div>
<span class="name">DashCA</span> <span class="name" data-i18n="card.dashca">DashCA</span>
<span class="spacer"></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>
<div class="response-row"> <div class="response-row">
<span id="time-ca" class="response-time">--</span> <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="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="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 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> </div>
</div> </div>
@@ -345,16 +345,16 @@
<!-- Service Filter Bar --> <!-- 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;"> <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;"> <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="all" data-i18n="filter.all_status">All Status</option>
<option value="on">🟢 Online</option> <option value="on" data-i18n="filter.online" data-i18n-prefix="🟢 ">🟢 Online</option>
<option value="off">🔴 Offline</option> <option value="off" data-i18n="filter.offline" data-i18n-prefix="🔴 ">🔴 Offline</option>
</select> </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;"> <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> </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> <span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
</div> </div>
@@ -760,14 +760,23 @@
var versionInfoClose = document.getElementById('version-info-close'); var versionInfoClose = document.getElementById('version-info-close');
var latestUpdateCheck = null; var latestUpdateCheck = null;
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function(character) {
return {
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
}[character];
});
}
function formatValue(value) { function formatValue(value) {
if (value == null || value === '') return '—'; if (value == null || value === '') return '—';
if (typeof value === 'object') return JSON.stringify(value); if (typeof value === 'object') return escapeHtml(JSON.stringify(value));
return String(value); return escapeHtml(value);
} }
function renderInfoRow(label, 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) { function renderHistory(history) {
+19 -3
View File
@@ -1,6 +1,12 @@
// ========== GRID & STATUS HELPERS ========== // ========== GRID & STATUS HELPERS ==========
(function () { (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 */ /* Enhanced status helpers with response time tracking */
function setQuick(id, up, responseTime = null) { function setQuick(id, up, responseTime = null) {
const dot = document.getElementById(id + '-dot'); const dot = document.getElementById(id + '-dot');
@@ -14,7 +20,7 @@
} }
if (pill) { if (pill) {
pill.textContent = up ? 'ON' : 'OFF'; pill.textContent = statusLabel(up);
pill.classList.toggle('on', up); pill.classList.toggle('on', up);
pill.classList.toggle('off', !up); pill.classList.toggle('off', !up);
} }
@@ -172,7 +178,10 @@
row.appendChild(el('span', 'spacer')); 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) // Update available badge (hidden by default, shown when update detected)
const updateBadge = el('span', 'update-available-badge', 'UPDATE'); const updateBadge = el('span', 'update-available-badge', 'UPDATE');
@@ -301,6 +310,7 @@
} }
const btn = el('button', null, 'Open'); const btn = el('button', null, 'Open');
btn.setAttribute('data-i18n', 'action.open');
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener'); btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
btnRow.appendChild(btn); btnRow.appendChild(btn);
card.appendChild(btnRow); card.appendChild(btnRow);
@@ -309,6 +319,12 @@
root.appendChild(card); 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(() => { requestAnimationFrame(() => {
root.querySelectorAll('.card').forEach(card => card.classList.add('loaded')); root.querySelectorAll('.card').forEach(card => card.classList.add('loaded'));
}); });
@@ -332,7 +348,7 @@
} }
if (pill) { if (pill) {
pill.textContent = up ? 'ON' : 'OFF'; pill.textContent = statusLabel(up);
pill.classList.toggle('on', up); pill.classList.toggle('on', up);
pill.classList.toggle('off', !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>` `<span id="${safeId}-dot" class="dot bad at-bl"></span>`
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>` + `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
+ `<span class="name">${label}</span><span class="spacer"></span>` + `<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="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="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">` + `<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}-update" class="update-btn" title="Update DNS server">⬆️</button>`
+ `<button id="${safeId}-open">Open</button>` + `<button id="${safeId}-open" data-i18n="action.open">Open</button>`
+ `<button id="${safeId}-logs" class="logs-btn">Logs</button>` + `<button id="${safeId}-logs" class="logs-btn" data-i18n="action.logs">Logs</button>`
+ `<button id="${safeId}-settings" class="settings-btn">⚙️</button>` + `<button id="${safeId}-settings" class="settings-btn">⚙️</button>`
+ `</div>`; + `</div>`;
topRow.insertBefore(card, firstChild); topRow.insertBefore(card, firstChild);
}); });
if (window.DCI18n && window.DCI18n.isLoaded()) {
window.DCI18n.applyTranslations();
}
} }
window.renderDnsCards = renderDnsCards; window.renderDnsCards = renderDnsCards;
+29 -18
View File
@@ -50,11 +50,6 @@
// reqId is the monotonic token incremented by the caller (setLanguage/init). // reqId is the monotonic token incremented by the caller (setLanguage/init).
// If not provided (direct API call), allocate one for backward compatibility. // If not provided (direct API call), allocate one for backward compatibility.
if (reqId === undefined) reqId = ++_langRequestId; if (reqId === undefined) reqId = ++_langRequestId;
if (lang === DEFAULT_LANG) {
translations = {}; // English is the default — no translation needed
loaded = true;
return;
}
try { try {
const res = await fetch(`/api/v1/i18n/translations/${lang}`); const res = await fetch(`/api/v1/i18n/translations/${lang}`);
// Guard against out-of-order resolution: if another loadTranslations // Guard against out-of-order resolution: if another loadTranslations
@@ -80,11 +75,15 @@
} }
function t(key) { function t(key) {
if (currentLang === DEFAULT_LANG) return key; // Translation keys are semantic identifiers, not English fallback copy.
// If translations didn't load, fall back to the English key // Callers that render before loading should retain their existing DOM text.
return translations[key] || key; return translations[key] || key;
} }
function isLoaded() {
return loaded;
}
function setLanguage(lang) { function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) return; if (!SUPPORTED_LANGS.includes(lang)) return;
currentLang = lang; currentLang = lang;
@@ -97,8 +96,9 @@
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
loadTranslations(lang, reqId).then(() => { loadTranslations(lang, reqId).then(() => {
// Only apply if this is still the latest request. // Only apply a successfully loaded dictionary. On HTTP/network failure,
if (reqId === _langRequestId) applyTranslations(); // 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. // leaving the previous language's translated text visible.
document.querySelectorAll('[data-i18n]').forEach(el => { document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n'); 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 // Apply to placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder'); 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 // Apply to titles
document.querySelectorAll('[data-i18n-title]').forEach(el => { document.querySelectorAll('[data-i18n-title]').forEach(el => {
@@ -226,12 +239,10 @@
function start() { function start() {
createLanguageSelector(); createLanguageSelector();
if (currentLang !== DEFAULT_LANG) { const reqId = ++_langRequestId;
const reqId = ++_langRequestId; loadTranslations(currentLang, reqId).then(() => {
loadTranslations(currentLang, reqId).then(() => { if (reqId === _langRequestId && loaded) applyTranslations();
if (reqId === _langRequestId) applyTranslations(); });
});
}
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -242,7 +253,7 @@
} }
// Expose globally // Expose globally
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations }; window.DCI18n = { t, setLanguage, getLanguage, isLoaded, applyTranslations, loadTranslations };
// Auto-init // Auto-init
init(); init();
+8 -3
View File
@@ -169,6 +169,11 @@
'4h': '4 hours', '8h': '8 hours', '12h': '12 hours', '24h': '24 hours', 'never': 'Disabled' '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) { function updateAuthCard(active, duration) {
const card = document.getElementById('auth-card'); const card = document.getElementById('auth-card');
const pill = document.getElementById('auth-pill'); const pill = document.getElementById('auth-pill');
@@ -179,15 +184,15 @@
if (active) { if (active) {
card.setAttribute('data-status', 'on'); card.setAttribute('data-status', 'on');
pill.className = 'badge on'; pill.className = 'badge on';
pill.textContent = 'YES'; pill.textContent = translated('card.status.yes', 'YES');
dot.className = 'dot ok at-bl'; dot.className = 'dot ok at-bl';
statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration); statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration);
} else { } else {
card.setAttribute('data-status', 'off'); card.setAttribute('data-status', 'off');
pill.className = 'badge off'; pill.className = 'badge off';
pill.textContent = 'NO'; pill.textContent = translated('card.status.no', 'NO');
dot.className = 'dot bad at-bl'; 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 = [ const PRECACHE = [
'/', '/',
'/index.html', '/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="🔍 "/);
});