[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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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': '批量操作',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+106
-106
File diff suppressed because one or more lines are too long
+29
-20
@@ -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 {
|
||||||
|
'&': '&', '<': '<', '>': '>',
|
||||||
|
'"': '"', "'": '''
|
||||||
|
}[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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
|
const CACHE = 'dashcaddy-shell-4a75cb88af';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const statusRoot = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function makeElement(attrs = {}, text = '', card = null) {
|
||||||
|
return {
|
||||||
|
attrs: { ...attrs }, textContent: text, placeholder: '', title: '',
|
||||||
|
getAttribute(name) { return this.attrs[name] || null; },
|
||||||
|
closest(selector) { return selector === '[data-status]' ? card : null; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadI18n(fetchImpl, elements = {}) {
|
||||||
|
const source = fs.readFileSync(path.join(statusRoot, 'js', 'i18n.js'), 'utf8');
|
||||||
|
const listeners = {};
|
||||||
|
const document = {
|
||||||
|
readyState: 'loading', documentElement: {},
|
||||||
|
addEventListener(name, fn) { listeners[name] = fn; },
|
||||||
|
querySelectorAll(selector) { return elements[selector] || []; },
|
||||||
|
querySelector() { return null; }, getElementById() { return null; },
|
||||||
|
createElement() { return { style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} }; },
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
window: {}, document, console, fetch: fetchImpl,
|
||||||
|
localStorage: { getItem() { return null; }, setItem() {} },
|
||||||
|
setTimeout, clearTimeout,
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, context, { filename: 'i18n.js' });
|
||||||
|
return { api: context.window.DCI18n, listeners };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('English dictionary is fetched and semantic keys never replace English card copy', async () => {
|
||||||
|
const card = makeElement({ 'data-i18n': 'card.internet' }, 'Internet');
|
||||||
|
const calls = [];
|
||||||
|
const { api } = loadI18n(async url => {
|
||||||
|
calls.push(url);
|
||||||
|
return { ok: true, async json() { return { translations: { 'card.internet': 'Internet' } }; } };
|
||||||
|
}, {
|
||||||
|
'[data-i18n]': [card], '[data-i18n-placeholder]': [],
|
||||||
|
'[data-i18n-live-status]': [], '[data-i18n-title]': [],
|
||||||
|
});
|
||||||
|
await api.loadTranslations('en');
|
||||||
|
api.applyTranslations();
|
||||||
|
assert.deepEqual(calls, ['/api/v1/i18n/translations/en']);
|
||||||
|
assert.equal(card.textContent, 'Internet');
|
||||||
|
assert.equal(api.isLoaded(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('failed language switch preserves existing readable labels', async () => {
|
||||||
|
const label = makeElement({ 'data-i18n': 'card.internet' }, 'Internet');
|
||||||
|
const elements = {
|
||||||
|
'[data-i18n]': [label], '[data-i18n-placeholder]': [],
|
||||||
|
'[data-i18n-live-status]': [], '[data-i18n-title]': [],
|
||||||
|
};
|
||||||
|
const { api } = loadI18n(async () => ({ ok: false, async json() { return {}; } }), elements);
|
||||||
|
api.setLanguage('es');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
assert.equal(api.isLoaded(), false);
|
||||||
|
assert.equal(label.textContent, 'Internet');
|
||||||
|
assert.notEqual(label.textContent, 'card.internet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('language switch preserves ON runtime state and translates a dynamic DNS pill immediately', async () => {
|
||||||
|
const onlineCard = { getAttribute(name) { return name === 'data-status' ? 'on' : null; } };
|
||||||
|
const staticPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard);
|
||||||
|
const dynamicDnsPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard);
|
||||||
|
const elements = {
|
||||||
|
'[data-i18n]': [], '[data-i18n-placeholder]': [],
|
||||||
|
'[data-i18n-live-status]': [staticPill, dynamicDnsPill], '[data-i18n-title]': [],
|
||||||
|
};
|
||||||
|
const { api } = loadI18n(async () => ({
|
||||||
|
ok: true,
|
||||||
|
async json() { return { translations: { 'card.status.on': 'ENC', 'card.status.off': 'APAG' } }; },
|
||||||
|
}), elements);
|
||||||
|
await api.loadTranslations('es');
|
||||||
|
api.applyTranslations();
|
||||||
|
assert.equal(staticPill.textContent, 'ENC');
|
||||||
|
assert.equal(dynamicDnsPill.textContent, 'ENC');
|
||||||
|
assert.notEqual(staticPill.textContent, 'APAG', 'online state must not reset to OFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic card template marks its pill and reapplies translations after insertion', () => {
|
||||||
|
const source = fs.readFileSync(path.join(statusRoot, 'js', 'globals.js'), 'utf8');
|
||||||
|
assert.match(source, /data-i18n-live-status="binary"/);
|
||||||
|
assert.match(source, /DCI18n\.isLoaded\(\)/);
|
||||||
|
assert.match(source, /DCI18n\.applyTranslations\(\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('ordinary service card built after i18n load is translated immediately', () => {
|
||||||
|
class Node {
|
||||||
|
constructor(tag = 'div') {
|
||||||
|
this.tag = tag; this.children = []; this.attrs = {}; this.textContent = '';
|
||||||
|
this.className = ''; this.id = ''; this.style = {};
|
||||||
|
this.classList = { add() {}, toggle() {} };
|
||||||
|
}
|
||||||
|
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||||
|
setAttribute(name, value) { this.attrs[name] = String(value); }
|
||||||
|
getAttribute(name) { return this.attrs[name] || null; }
|
||||||
|
closest(selector) {
|
||||||
|
if (selector === '[data-status]' && this.attrs['data-status']) return this;
|
||||||
|
return this.parentNode ? this.parentNode.closest(selector) : null;
|
||||||
|
}
|
||||||
|
addEventListener() {}
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
const found = [];
|
||||||
|
const visit = node => {
|
||||||
|
const attr = selector.match(/^\[([^\]]+)\]$/);
|
||||||
|
if (attr && Object.prototype.hasOwnProperty.call(node.attrs, attr[1])) found.push(node);
|
||||||
|
if (selector === '.card' && node.className.split(/\s+/).includes('card')) found.push(node);
|
||||||
|
node.children.forEach(visit);
|
||||||
|
};
|
||||||
|
this.children.forEach(visit);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cards = new Node('section');
|
||||||
|
const document = {
|
||||||
|
createElement(tag) { return new Node(tag); },
|
||||||
|
getElementById(id) { return id === 'cards' ? cards : null; },
|
||||||
|
querySelector() { return null; },
|
||||||
|
};
|
||||||
|
const translations = { 'card.status.off': 'APAG', 'action.open': 'Abrir' };
|
||||||
|
const window = {
|
||||||
|
APPS: [{ id: 'demo', name: 'Demo', logo: '/demo.png' }],
|
||||||
|
DCI18n: {
|
||||||
|
isLoaded() { return true; },
|
||||||
|
t(key) { return translations[key] || key; },
|
||||||
|
applyTranslations() {
|
||||||
|
cards.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
el.textContent = this.t(el.getAttribute('data-i18n'));
|
||||||
|
});
|
||||||
|
cards.querySelectorAll('[data-i18n-live-status]').forEach(el => {
|
||||||
|
const card = el.closest('[data-status]');
|
||||||
|
const key = card.getAttribute('data-status') === 'on' ? 'card.status.on' : 'card.status.off';
|
||||||
|
el.textContent = this.t(key);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
open() {},
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
window, document, console, SITE: { dnsServers: {} },
|
||||||
|
buildServiceUrl(id) { return 'https://' + id + '.sami'; },
|
||||||
|
requestAnimationFrame(fn) { fn(); }, fetch: async () => ({ ok: true }),
|
||||||
|
setTimeout, clearTimeout, performance: { now() { return 0; } },
|
||||||
|
};
|
||||||
|
const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8');
|
||||||
|
vm.runInNewContext(source, context, { filename: 'grid.js' });
|
||||||
|
// grid.js initializes APPS itself; emulate service loading after module init.
|
||||||
|
window.APPS = [{ id: 'demo', name: 'Demo', logo: '/demo.png' }];
|
||||||
|
window.buildGrid();
|
||||||
|
const livePills = cards.querySelectorAll('[data-i18n-live-status]');
|
||||||
|
const openButtons = cards.querySelectorAll('[data-i18n]');
|
||||||
|
assert.equal(livePills.length, 1);
|
||||||
|
assert.equal(livePills[0].textContent, 'APAG');
|
||||||
|
assert.equal(openButtons.length, 1);
|
||||||
|
assert.equal(openButtons[0].textContent, 'Abrir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live health polling uses translated ON and OFF labels', () => {
|
||||||
|
const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8');
|
||||||
|
assert.match(source, /card\.status\.on/);
|
||||||
|
assert.match(source, /card\.status\.off/);
|
||||||
|
assert.doesNotMatch(source, /pill\.textContent\s*=\s*up\s*\?\s*['"]ON['"]\s*:\s*['"]OFF['"]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('version modal escapes malicious API metadata before innerHTML rendering', () => {
|
||||||
|
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||||
|
const script = html.match(/<script>\s*\(function\(\) \{[\s\S]*?function escapeHtml[\s\S]*?window\.applyVersionUpdate = applyVersionUpdate;[\s\S]*?<\/script>/);
|
||||||
|
assert.ok(script, 'expected inline version modal script');
|
||||||
|
const escapeSource = script[0].match(/function escapeHtml\(value\) \{[\s\S]*?\n \}/)[0];
|
||||||
|
const formatSource = script[0].match(/function formatValue\(value\) \{[\s\S]*?\n \}/)[0];
|
||||||
|
const rowSource = script[0].match(/function renderInfoRow\(label, value\) \{[\s\S]*?\n \}/)[0];
|
||||||
|
const context = {};
|
||||||
|
vm.runInNewContext(escapeSource + '\n' + formatSource + '\n' + rowSource, context);
|
||||||
|
const payload = '<img src=x onerror="globalThis.pwned=1">';
|
||||||
|
const row = context.renderInfoRow(payload, payload);
|
||||||
|
assert.doesNotMatch(row, /<img\b/i);
|
||||||
|
assert.doesNotMatch(row, /onerror="/i);
|
||||||
|
assert.match(row, /<img/);
|
||||||
|
assert.match(row, /"globalThis\.pwned=1"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index loads the generated core bundle containing live-status translation logic', () => {
|
||||||
|
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||||
|
const bundle = fs.readFileSync(path.join(statusRoot, 'dist', 'core.js'), 'utf8');
|
||||||
|
assert.match(html, /<script src="\/dist\/core\.js" defer><\/script>/);
|
||||||
|
assert.match(bundle, /data-i18n-live-status/);
|
||||||
|
assert.match(bundle, /card\.status\.on/);
|
||||||
|
assert.match(bundle, /applyTranslations/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('translated controls preserve their visual glyph prefixes', () => {
|
||||||
|
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||||
|
assert.match(html, /data-i18n="filter\.online" data-i18n-prefix="🟢 "/);
|
||||||
|
assert.match(html, /data-i18n="filter\.offline" data-i18n-prefix="🔴 "/);
|
||||||
|
assert.match(html, /data-i18n="filter\.batch_operations" data-i18n-prefix="☰ "/);
|
||||||
|
assert.match(html, /data-i18n-placeholder="filter\.services_placeholder" data-i18n-prefix="🔍 "/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user