Merge remote-tracking branch 'origin/benphelpsMain' into wg-easy-service-widget

This commit is contained in:
Karl Hudgell 2023-06-16 09:06:33 +01:00
commit 0c3c170b0c
104 changed files with 1939 additions and 1208 deletions

View File

@ -23,7 +23,7 @@
"free": "متاح", "free": "متاح",
"used": "مستخدم", "used": "مستخدم",
"load": "الضغط", "load": "الضغط",
"mem": "MEM", "mem": "الذاكرة",
"temp": "TEMP", "temp": "TEMP",
"max": "Max", "max": "Max",
"uptime": "UP", "uptime": "UP",
@ -134,7 +134,7 @@
"episodes": "Episodes" "episodes": "Episodes"
}, },
"changedetectionio": { "changedetectionio": {
"totalObserved": "Total Observed", "totalObserved": "مجموع الملاحظات",
"diffsDetected": "Diffs Detected" "diffsDetected": "Diffs Detected"
}, },
"tautulli": { "tautulli": {
@ -179,18 +179,22 @@
"sonarr": { "sonarr": {
"wanted": "مطلوب", "wanted": "مطلوب",
"queued": "في الإنتظار", "queued": "في الإنتظار",
"series": "سلسلة" "series": "سلسلة",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "مطلوب", "wanted": "مطلوب",
"missing": "مفقود", "missing": "مفقود",
"queued": "في الإنتظار", "queued": "في الإنتظار",
"movies": "أفلام" "movies": "أفلام",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "مطلوب", "wanted": "مطلوب",
"queued": "في الإنتظار", "queued": "في الإنتظار",
"albums": "ألبومات" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "مطلوب", "wanted": "مطلوب",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -117,18 +117,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"movies": "Movies", "movies": "Movies",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -26,7 +26,9 @@
"sonarr": { "sonarr": {
"wanted": "Volgut", "wanted": "Volgut",
"queued": "En cua", "queued": "En cua",
"series": "Sèries" "series": "Sèries",
"queue": "Queue",
"unknown": "Unknown"
}, },
"speedtest": { "speedtest": {
"ping": "Ping", "ping": "Ping",
@ -99,7 +101,9 @@
"wanted": "Volgut", "wanted": "Volgut",
"queued": "En cua", "queued": "En cua",
"movies": "Pel·lícules", "movies": "Pel·lícules",
"missing": "Faltant" "missing": "Faltant",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Volgut", "wanted": "Volgut",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Volgut", "wanted": "Volgut",
"queued": "En cua", "queued": "En cua",
"albums": "Àlbums" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Consultes", "queries": "Consultes",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -133,18 +133,22 @@
"sonarr": { "sonarr": {
"wanted": "Hledané", "wanted": "Hledané",
"queued": "Ve frontě", "queued": "Ve frontě",
"series": "Seriály" "series": "Seriály",
"unknown": "Unknown",
"queue": "Queue"
}, },
"radarr": { "radarr": {
"wanted": "Hledané", "wanted": "Hledané",
"missing": "Chybějící", "missing": "Chybějící",
"queued": "Ve frontě", "queued": "Ve frontě",
"movies": "Filmy" "movies": "Filmy",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Hledané", "wanted": "Hledané",
"queued": "Ve frontě", "queued": "Ve frontě",
"albums": "Alba" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Hledané", "wanted": "Hledané",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadSpeed": "Download Speed",
"downloadCount": "Queue Count",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -9,12 +9,14 @@
"queued": "I Kø", "queued": "I Kø",
"movies": "Film", "movies": "Film",
"wanted": "Ønskede", "wanted": "Ønskede",
"missing": "Mangler" "missing": "Mangler",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Ønsket", "wanted": "Ønsket",
"queued": "I Kø", "queued": "I Kø",
"albums": "Albums" "artists": "Artists"
}, },
"jellyseerr": { "jellyseerr": {
"available": "Tilgængelig", "available": "Tilgængelig",
@ -264,7 +266,9 @@
"sonarr": { "sonarr": {
"wanted": "Ønsket", "wanted": "Ønsket",
"queued": "I Kø", "queued": "I Kø",
"series": "Serier" "series": "Serier",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Ønskede", "wanted": "Ønskede",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadSpeed": "Download Speed",
"downloadCount": "Queue Count",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Gesucht", "wanted": "Gesucht",
"queued": "In Warteschlange", "queued": "In Warteschlange",
"series": "Serien" "series": "Serien",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Gesucht", "wanted": "Gesucht",
"queued": "In Warteschlange", "queued": "In Warteschlange",
"movies": "Filme", "movies": "Filme",
"missing": "Fehlt" "missing": "Fehlt",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Gesucht", "wanted": "Gesucht",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Gesucht", "wanted": "Gesucht",
"queued": "In Warteschlange", "queued": "In Warteschlange",
"albums": "Alben" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Anfragen", "queries": "Anfragen",
@ -640,5 +644,11 @@
"connected": "Verbunden", "connected": "Verbunden",
"new_devices": "Neue Geräte", "new_devices": "Neue Geräte",
"down_alerts": "Down Alarme" "down_alerts": "Down Alarme"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -206,7 +206,9 @@
"sonarr": { "sonarr": {
"series": "Σειρές", "series": "Σειρές",
"wanted": "Επιθυμούντε", "wanted": "Επιθυμούντε",
"queued": "Σε σειρά" "queued": "Σε σειρά",
"queue": "Queue",
"unknown": "Unknown"
}, },
"downloadstation": { "downloadstation": {
"download": "Μεταφόρτωση", "download": "Μεταφόρτωση",
@ -218,12 +220,14 @@
"wanted": "Επιθυμούντε", "wanted": "Επιθυμούντε",
"missing": "Απουσιάζει", "missing": "Απουσιάζει",
"queued": "Σε σειρά", "queued": "Σε σειρά",
"movies": "Ταινίες" "movies": "Ταινίες",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Θέλετε", "wanted": "Θέλετε",
"queued": "Στη σειρά", "queued": "Στη σειρά",
"albums": "Δίσκοι" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Θέλετε", "wanted": "Θέλετε",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -194,18 +194,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Movies" "movies": "Movies",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -650,11 +654,13 @@
"monitoring": "Monitoring", "monitoring": "Monitoring",
"updates": "Updates" "updates": "Updates"
}, },
"nextpvr": {
"upcoming": "Upcoming",
"ready": "Recent"
},
"wgeasy": { "wgeasy": {
"clients": "Total Clients" "clients": "Total Clients"
},
"jdownloader": {
"downloadCount": "Queue",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size",
"downloadSpeed": "Speed"
} }
} }

View File

@ -131,18 +131,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Serio" "series": "Serio",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Filmoj" "movies": "Filmoj",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albumoj" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Buscando", "wanted": "Buscando",
"queued": "En cola", "queued": "En cola",
"series": "Series" "series": "Series",
"queue": "Poner a la cola",
"unknown": "Desconocido"
}, },
"radarr": { "radarr": {
"wanted": "Buscando", "wanted": "Buscando",
"queued": "En cola", "queued": "En cola",
"movies": "Películas", "movies": "Películas",
"missing": "Faltan" "missing": "Faltan",
"queue": "Poner a la cola",
"unknown": "Desconocido"
}, },
"readarr": { "readarr": {
"wanted": "Buscando", "wanted": "Buscando",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"queued": "En cola", "queued": "En cola",
"wanted": "Buscando", "wanted": "Buscando",
"albums": "Álbumes" "artists": "Artistas"
}, },
"adguard": { "adguard": {
"queries": "Consultas", "queries": "Consultas",
@ -640,5 +644,11 @@
"connected": "Conectado", "connected": "Conectado",
"new_devices": "Nuevos dispositivos", "new_devices": "Nuevos dispositivos",
"down_alerts": "Alertas" "down_alerts": "Alertas"
},
"jdownloader": {
"downloadCount": "Recuento de las colas",
"downloadSpeed": "Velocidad de Descarga",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -94,18 +94,22 @@
"sonarr": { "sonarr": {
"wanted": "Haluttu", "wanted": "Haluttu",
"queued": "Jonossa", "queued": "Jonossa",
"series": "Sarja" "series": "Sarja",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Haluttu", "wanted": "Haluttu",
"queued": "Jonossa", "queued": "Jonossa",
"movies": "Elokuvia", "movies": "Elokuvia",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Haluttu", "wanted": "Haluttu",
"queued": "Jonossa", "queued": "Jonossa",
"albums": "Albumeja" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Haluttu", "wanted": "Haluttu",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Demande", "wanted": "Demande",
"queued": "Attente", "queued": "Attente",
"series": "Séries" "series": "Séries",
"queue": "Attente",
"unknown": "Inconnu"
}, },
"radarr": { "radarr": {
"wanted": "Demande", "wanted": "Demande",
"queued": "Attente", "queued": "Attente",
"movies": "Films", "movies": "Films",
"missing": "Manquant" "missing": "Manquant",
"queue": "Attente",
"unknown": "Inconnu"
}, },
"readarr": { "readarr": {
"wanted": "Demande", "wanted": "Demande",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Demandé", "wanted": "Demandé",
"queued": "En queue", "queued": "En queue",
"albums": "Albums" "artists": "Artistes"
}, },
"adguard": { "adguard": {
"queries": "Requêtes", "queries": "Requêtes",
@ -397,7 +401,7 @@
"queue": "À traiter", "queue": "À traiter",
"processed": "Traité", "processed": "Traité",
"errored": "En erreur", "errored": "En erreur",
"saved": "Economisé" "saved": "Libéré"
}, },
"miniflux": { "miniflux": {
"read": "Lu", "read": "Lu",
@ -640,5 +644,11 @@
"connected": "Connecté", "connected": "Connecté",
"new_devices": "Nouvel Appareil", "new_devices": "Nouvel Appareil",
"down_alerts": "Alertes" "down_alerts": "Alertes"
},
"jdownloader": {
"downloadCount": "Total en attente",
"downloadSpeed": "Vitesse de téléchargement",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -94,18 +94,22 @@
"sonarr": { "sonarr": {
"wanted": "מבוקש", "wanted": "מבוקש",
"queued": "בתור", "queued": "בתור",
"series": "סדרות" "series": "סדרות",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "מבוקש", "wanted": "מבוקש",
"queued": "בתור", "queued": "בתור",
"movies": "סרטים", "movies": "סרטים",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "מבוקש", "wanted": "מבוקש",
"queued": "בתור", "queued": "בתור",
"albums": "אלבומים" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "מבוקש", "wanted": "מבוקש",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -155,18 +155,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Movies" "movies": "Movies",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"overseerr": { "overseerr": {
"pending": "Pending", "pending": "Pending",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -125,18 +125,22 @@
"sonarr": { "sonarr": {
"wanted": "Zatraženo", "wanted": "Zatraženo",
"queued": "U redu čekanja", "queued": "U redu čekanja",
"series": "Serije" "series": "Serije",
"unknown": "Unknown",
"queue": "Queue"
}, },
"radarr": { "radarr": {
"wanted": "Zatraženo", "wanted": "Zatraženo",
"queued": "U redu čekanja", "queued": "U redu čekanja",
"movies": "Filmovi", "movies": "Filmovi",
"missing": "Nedostaje" "missing": "Nedostaje",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Zatraženo", "wanted": "Zatraženo",
"queued": "U redu čekanja", "queued": "U redu čekanja",
"albums": "Albumi" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Zatraženo", "wanted": "Zatraženo",
@ -239,11 +243,11 @@
"uptime": "UP", "uptime": "UP",
"days": "d", "days": "d",
"hours": "h", "hours": "h",
"used": "Used", "used": "Korišteno",
"load": "Load", "load": "Opterećenje",
"warn": "Warn", "warn": "Upozori",
"total": "Total", "total": "Ukupno",
"free": "Free" "free": "Slobodno"
}, },
"changedetectionio": { "changedetectionio": {
"totalObserved": "Ukupno promatrano", "totalObserved": "Ukupno promatrano",
@ -478,7 +482,7 @@
"up": "Aktivne stranice", "up": "Aktivne stranice",
"down": "Neaktivne stranice", "down": "Neaktivne stranice",
"uptime": "Radno vrijeme", "uptime": "Radno vrijeme",
"incident": "Incident", "incident": "Slučaj",
"m": "min" "m": "min"
}, },
"komga": { "komga": {
@ -609,36 +613,42 @@
"poolUsage": "Korištenje memorijskog skupa", "poolUsage": "Korištenje memorijskog skupa",
"cpuUsage": "Korištenje procesora", "cpuUsage": "Korištenje procesora",
"memUsage": "Korištenje memorije", "memUsage": "Korištenje memorije",
"volumeUsage": "Volume Usage", "volumeUsage": "Korištenje jedinice memorije",
"invalid": "Invalid" "invalid": "Neispravno"
}, },
"pfsense": { "pfsense": {
"load": "Load Avg", "load": "Prosječno opterećenje",
"memory": "Mem Usage", "memory": "Korištenje memorije",
"wanStatus": "WAN Status", "wanStatus": "Stanje WAN-a",
"up": "Up", "up": "Up",
"down": "Down", "down": "Down",
"temp": "Temp", "temp": "Temperatura",
"disk": "Disk Usage", "disk": "Korištenje diska",
"wanIP": "WAN IP" "wanIP": "WAN IP"
}, },
"caddy": { "caddy": {
"upstreams": "Upstreams", "upstreams": "Glavne grane",
"requests": "Current requests", "requests": "Aktualni zahtjevi",
"requests_failed": "Failed requests" "requests_failed": "Neuspjeli zahtjevi"
}, },
"evcc": { "evcc": {
"pv_power": "Production", "pv_power": "Proizvodnja",
"battery_soc": "Battery", "battery_soc": "Baterija",
"grid_power": "Grid", "grid_power": "Raspored",
"home_power": "Consumption", "home_power": "Potrošnja",
"charge_power": "Charger", "charge_power": "Punjač",
"watt_hour": "Wh" "watt_hour": "Wh"
}, },
"pialert": { "pialert": {
"total": "Total", "total": "Ukupno",
"connected": "Connected", "connected": "Povezano",
"new_devices": "New Devices", "new_devices": "Novi uređaji",
"down_alerts": "Down Alerts" "down_alerts": "Obavijest o rušenju"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -31,9 +31,9 @@
"healthy": "Healthy" "healthy": "Healthy"
}, },
"lidarr": { "lidarr": {
"albums": "Albumok",
"wanted": "Keresett", "wanted": "Keresett",
"queued": "Sorban áll" "queued": "Sorban áll",
"artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Keresett", "wanted": "Keresett",
@ -108,13 +108,17 @@
"sonarr": { "sonarr": {
"wanted": "Keresett", "wanted": "Keresett",
"queued": "Sorban áll", "queued": "Sorban áll",
"series": "Sorozat" "series": "Sorozat",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Keresett", "wanted": "Keresett",
"queued": "Sorban áll", "queued": "Sorban áll",
"movies": "Filmek", "movies": "Filmek",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"ombi": { "ombi": {
"pending": "Függőben", "pending": "Függőben",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadSpeed": "Download Speed",
"downloadCount": "Queue Count",
"downloadTotalBytes": "Size",
"downloadBytesRemaining": "Remaining"
} }
} }

View File

@ -55,18 +55,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Movies" "movies": "Movies",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -640,5 +644,11 @@
"transcoding": "Transcoding", "transcoding": "Transcoding",
"bitrate": "Bitrate", "bitrate": "Bitrate",
"no_active": "No Active Streams" "no_active": "No Active Streams"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -81,13 +81,17 @@
"sonarr": { "sonarr": {
"series": "Serie", "series": "Serie",
"wanted": "Richiesti", "wanted": "Richiesti",
"queued": "In coda" "queued": "In coda",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Richiesti", "wanted": "Richiesti",
"queued": "In coda", "queued": "In coda",
"movies": "Film", "movies": "Film",
"missing": "Mancanti" "missing": "Mancanti",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Richiesti", "wanted": "Richiesti",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Mancanti", "wanted": "Mancanti",
"queued": "In coda", "queued": "In coda",
"albums": "Album" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Interrogazioni", "queries": "Interrogazioni",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -63,7 +63,7 @@
"resources": { "resources": {
"cpu": "CPU", "cpu": "CPU",
"total": "合計", "total": "合計",
"free": "フリー", "free": "Free",
"used": "使用", "used": "使用",
"load": "ロード", "load": "ロード",
"mem": "MEM", "mem": "MEM",
@ -193,18 +193,22 @@
"sonarr": { "sonarr": {
"wanted": "募集中", "wanted": "募集中",
"queued": "待機中", "queued": "待機中",
"series": "シリーズ" "series": "シリーズ",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "募集中", "wanted": "募集中",
"missing": "不明", "missing": "不明",
"queued": "キュー", "queued": "キュー",
"movies": "映画" "movies": "映画",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "募集中", "wanted": "募集中",
"queued": "キュー", "queued": "キュー",
"albums": "アルバム" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "募集中", "wanted": "募集中",
@ -605,11 +609,11 @@
"ago": "{{value}} 前" "ago": "{{value}} 前"
}, },
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "CPU使用量",
"memUsage": "MEM Usage", "memUsage": "MEM使用量",
"systemTempC": "System Temp", "systemTempC": "システム温度",
"poolUsage": "Pool Usage", "poolUsage": "プール使用量",
"volumeUsage": "Volume Usage", "volumeUsage": "ボリューム使用量",
"invalid": "Invalid" "invalid": "Invalid"
}, },
"pfsense": { "pfsense": {
@ -629,16 +633,22 @@
}, },
"evcc": { "evcc": {
"watt_hour": "Wh", "watt_hour": "Wh",
"pv_power": "Production", "pv_power": "発電量",
"battery_soc": "Battery", "battery_soc": "バッテリー",
"grid_power": "Grid", "grid_power": "グリッド",
"home_power": "Consumption", "home_power": "消費",
"charge_power": "Charger" "charge_power": "チャージャー"
}, },
"pialert": { "pialert": {
"total": "Total", "total": "Total",
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -163,18 +163,22 @@
"sonarr": { "sonarr": {
"wanted": "요청", "wanted": "요청",
"queued": "대기 중", "queued": "대기 중",
"series": "시리즈" "series": "시리즈",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "요청", "wanted": "요청",
"missing": "빠짐", "missing": "빠짐",
"queued": "대기 중", "queued": "대기 중",
"movies": "영화" "movies": "영화",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "요청", "wanted": "요청",
"queued": "대기 중", "queued": "대기 중",
"albums": "앨범" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "요청", "wanted": "요청",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"down_alerts": "Down Alerts", "down_alerts": "Down Alerts",
"new_devices": "New Devices" "new_devices": "New Devices"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -154,18 +154,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Filmas" "movies": "Filmas",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albumi" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -33,8 +33,8 @@
}, },
"lidarr": { "lidarr": {
"queued": "Dibaris Gilir", "queued": "Dibaris Gilir",
"albums": "Album", "wanted": "Mahu",
"wanted": "Mahu" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Mahu", "wanted": "Mahu",
@ -233,13 +233,17 @@
"sonarr": { "sonarr": {
"wanted": "Mahu", "wanted": "Mahu",
"queued": "Dibaris Gilir", "queued": "Dibaris Gilir",
"series": "Bersiri" "series": "Bersiri",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Mahu", "wanted": "Mahu",
"missing": "Hilang", "missing": "Hilang",
"queued": "Dibaris Gilir", "queued": "Dibaris Gilir",
"movies": "Filem" "movies": "Filem",
"queue": "Queue",
"unknown": "Unknown"
}, },
"bazarr": { "bazarr": {
"missingEpisodes": "Episod Yang Hilang", "missingEpisodes": "Episod Yang Hilang",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Ønsket", "wanted": "Ønsket",
"queued": "I kø", "queued": "I kø",
"series": "Serie" "series": "Serie",
"unknown": "Unknown",
"queue": "Queue"
}, },
"radarr": { "radarr": {
"wanted": "Ønsket", "wanted": "Ønsket",
"queued": "I kø", "queued": "I kø",
"movies": "Filmer", "movies": "Filmer",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Queries", "queries": "Queries",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -82,13 +82,17 @@
"sonarr": { "sonarr": {
"wanted": "Gezocht", "wanted": "Gezocht",
"queued": "In de wachtrij", "queued": "In de wachtrij",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"movies": "Films", "movies": "Films",
"wanted": "Gezocht", "wanted": "Gezocht",
"queued": "In de wachtrij", "queued": "In de wachtrij",
"missing": "Missend" "missing": "Missend",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Gezocht", "wanted": "Gezocht",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Gezocht", "wanted": "Gezocht",
"queued": "In de wachtrij", "queued": "In de wachtrij",
"albums": "Albums" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Queries", "queries": "Queries",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -110,18 +110,22 @@
"sonarr": { "sonarr": {
"wanted": "Poszukiwane", "wanted": "Poszukiwane",
"queued": "W kolejce", "queued": "W kolejce",
"series": "Seriale" "series": "Seriale",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Poszukiwane", "wanted": "Poszukiwane",
"queued": "W kolejce", "queued": "W kolejce",
"movies": "Filmy", "movies": "Filmy",
"missing": "Brakujące" "missing": "Brakujące",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Poszukiwane", "wanted": "Poszukiwane",
"queued": "W kolejce", "queued": "W kolejce",
"albums": "Albumy" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Poszukiwane", "wanted": "Poszukiwane",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -112,18 +112,22 @@
"sonarr": { "sonarr": {
"wanted": "Desejado", "wanted": "Desejado",
"queued": "Na fila", "queued": "Na fila",
"series": "Séries" "series": "Séries",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Desejado", "wanted": "Desejado",
"queued": "Na fila", "queued": "Na fila",
"movies": "Filmes", "movies": "Filmes",
"missing": "Faltando" "missing": "Faltando",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Desejado", "wanted": "Desejado",
"queued": "Na fila", "queued": "Na fila",
"albums": "Álbuns" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Desejado", "wanted": "Desejado",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Desejada", "wanted": "Desejada",
"queued": "Em fila", "queued": "Em fila",
"series": "Séries" "series": "Séries",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Desejado", "wanted": "Desejado",
"queued": "Fila", "queued": "Fila",
"movies": "Filmes", "movies": "Filmes",
"missing": "Faltando" "missing": "Faltando",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Desejados", "wanted": "Desejados",
@ -186,7 +190,7 @@
"lidarr": { "lidarr": {
"queued": "Enfileirado", "queued": "Enfileirado",
"wanted": "Desejado", "wanted": "Desejado",
"albums": "Álbuns" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Consultas", "queries": "Consultas",
@ -586,12 +590,12 @@
"switches_on": "Interruptores Ligados" "switches_on": "Interruptores Ligados"
}, },
"freshrss": { "freshrss": {
"subscriptions": "Subscriptions", "subscriptions": "Assinaturas",
"unread": "Unread" "unread": "Não lida"
}, },
"channelsdvrserver": { "channelsdvrserver": {
"shows": "Shows", "shows": "Shows",
"recordings": "Recordings", "recordings": "Gravações",
"scheduled": "Scheduled", "scheduled": "Scheduled",
"passes": "Passes" "passes": "Passes"
}, },
@ -633,21 +637,27 @@
}, },
"caddy": { "caddy": {
"upstreams": "Upstreams", "upstreams": "Upstreams",
"requests": "Current requests", "requests": "Solicitações atuais",
"requests_failed": "Failed requests" "requests_failed": "Solicitações com falha"
}, },
"evcc": { "evcc": {
"pv_power": "Production", "pv_power": "Produção",
"battery_soc": "Battery", "battery_soc": "Bateria",
"grid_power": "Grid", "grid_power": "Grade",
"home_power": "Consumption", "home_power": "Consumo",
"charge_power": "Charger", "charge_power": "Carregador",
"watt_hour": "Wh" "watt_hour": "Kw"
}, },
"pialert": { "pialert": {
"total": "Total", "total": "Total",
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -134,18 +134,22 @@
"sonarr": { "sonarr": {
"wanted": "Dorite", "wanted": "Dorite",
"queued": "În coadă", "queued": "În coadă",
"series": "Seriale" "series": "Seriale",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"queued": "În coadă", "queued": "În coadă",
"wanted": "Dorite", "wanted": "Dorite",
"movies": "Filme", "movies": "Filme",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Dorite", "wanted": "Dorite",
"queued": "În coadă", "queued": "În coadă",
"albums": "Albume" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Dorite", "wanted": "Dorite",
@ -640,5 +644,11 @@
"down_alerts": "Down Alerts", "down_alerts": "Down Alerts",
"total": "Total", "total": "Total",
"connected": "Connected" "connected": "Connected"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Хотел", "wanted": "Хотел",
"queued": "В очереди", "queued": "В очереди",
"series": "Серии" "series": "Серии",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Хотел", "wanted": "Хотел",
"queued": "В очереди", "queued": "В очереди",
"movies": "Фильмы", "movies": "Фильмы",
"missing": "Пропущено" "missing": "Пропущено",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Хотел", "wanted": "Хотел",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Хотел", "wanted": "Хотел",
"queued": "В очереди", "queued": "В очереди",
"albums": "Альбомы" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Запросы", "queries": "Запросы",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -273,18 +273,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Movies" "movies": "Movies",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -235,18 +235,22 @@
"sonarr": { "sonarr": {
"wanted": "Iskano", "wanted": "Iskano",
"queued": "V vrsti", "queued": "V vrsti",
"series": "Serije" "series": "Serije",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Iskano", "wanted": "Iskano",
"missing": "Manjka", "missing": "Manjka",
"queued": "V vrsti", "queued": "V vrsti",
"movies": "Filmi" "movies": "Filmi",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Iskano", "wanted": "Iskano",
"queued": "V vrsti", "queued": "V vrsti",
"albums": "Albumi" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Iskano", "wanted": "Iskano",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -117,18 +117,22 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"movies": "Movies", "movies": "Movies",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Wanted", "wanted": "Wanted",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -88,18 +88,22 @@
"sonarr": { "sonarr": {
"wanted": "Eftersöker", "wanted": "Eftersöker",
"queued": "I kö", "queued": "I kö",
"series": "Serier" "series": "Serier",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Eftersöker", "wanted": "Eftersöker",
"queued": "I kö", "queued": "I kö",
"movies": "Filmer", "movies": "Filmer",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Eftersöker", "wanted": "Eftersöker",
"queued": "I kö", "queued": "I kö",
"albums": "Album" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Eftersökt", "wanted": "Eftersökt",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -111,18 +111,22 @@
"sonarr": { "sonarr": {
"wanted": "కావలెను", "wanted": "కావలెను",
"queued": "క్యూయూఎడ్", "queued": "క్యూయూఎడ్",
"series": "సిరీస్" "series": "సిరీస్",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "కావలెను", "wanted": "కావలెను",
"queued": "క్యూయూఎడ్", "queued": "క్యూయూఎడ్",
"movies": "సినిమాలు", "movies": "సినిమాలు",
"missing": "మిస్సింగ్" "missing": "మిస్సింగ్",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "కావలెను", "wanted": "కావలెను",
"queued": "క్యూయూఎడ్", "queued": "క్యూయూఎడ్",
"albums": "ఆల్బములు" "artists": "Artists"
}, },
"bazarr": { "bazarr": {
"missingEpisodes": "ఎపిసోడ్‌లు లేవు", "missingEpisodes": "ఎపిసోడ్‌లు లేవు",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -190,7 +190,9 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"queued": "Queued", "queued": "Queued",
@ -216,12 +218,14 @@
"wanted": "Wanted", "wanted": "Wanted",
"missing": "Missing", "missing": "Missing",
"queued": "Queued", "queued": "Queued",
"movies": "Movies" "movies": "Movies",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"ombi": { "ombi": {
"pending": "Pending", "pending": "Pending",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -24,13 +24,13 @@
"used": "Kullanımda", "used": "Kullanımda",
"load": "Yük", "load": "Yük",
"mem": "MEM", "mem": "MEM",
"temp": "TEMP", "temp": "Geçici",
"max": "Max", "max": "En Yüksek",
"uptime": "UP", "uptime": "Çalışma Süresi",
"months": "mo", "months": "Ay",
"days": "d", "days": "Gün",
"hours": "h", "hours": "Saat",
"minutes": "m" "minutes": "Dakika"
}, },
"unifi": { "unifi": {
"users": "Kullanıcılar", "users": "Kullanıcılar",
@ -57,23 +57,23 @@
"offline": "Çevrimdışı", "offline": "Çevrimdışı",
"error": "Hata", "error": "Hata",
"unknown": "Bilinmiyor", "unknown": "Bilinmiyor",
"running": "Running", "running": "Çalışan",
"starting": "Starting", "starting": "Başlatılıyor",
"unhealthy": "Unhealthy", "unhealthy": "Sağlıksız",
"not_found": "Not Found", "not_found": "Bulunamadı",
"exited": "Exited", "exited": "Durduruldu",
"partial": "Partial", "partial": "Parçalı",
"healthy": "Healthy" "healthy": "Sağlık"
}, },
"emby": { "emby": {
"playing": "Oynatılıyor", "playing": "Oynatılıyor",
"transcoding": "Dönüştürülüyor", "transcoding": "Dönüştürülüyor",
"bitrate": "Bit Oranı", "bitrate": "Bit Oranı",
"no_active": "Aktif akış yok", "no_active": "Aktif akış yok",
"movies": "Movies", "movies": "Filmler",
"series": "Series", "series": "Diziler",
"episodes": "Episodes", "episodes": "Bölümler",
"songs": "Songs" "songs": "Şarkılar"
}, },
"tautulli": { "tautulli": {
"playing": "Oynatılıyor", "playing": "Oynatılıyor",
@ -90,7 +90,7 @@
"streams": "Aktif Akış", "streams": "Aktif Akış",
"movies": "Filmler", "movies": "Filmler",
"tv": "TV Showları", "tv": "TV Showları",
"albums": "Albums" "albums": "Albümler"
}, },
"sabnzbd": { "sabnzbd": {
"rate": "Oran", "rate": "Oran",
@ -117,18 +117,22 @@
"sonarr": { "sonarr": {
"wanted": "Aranan", "wanted": "Aranan",
"queued": "Kuyrukta", "queued": "Kuyrukta",
"series": "Seriler" "series": "Seriler",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Aranan", "wanted": "Aranan",
"queued": "Kuyrukta", "queued": "Kuyrukta",
"movies": "Filmler", "movies": "Filmler",
"missing": "Kayıp" "missing": "Kayıp",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "Aranan", "wanted": "Aranan",
"queued": "Kuyrukta", "queued": "Kuyrukta",
"albums": "Albümler" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "Aranan", "wanted": "Aranan",
@ -159,7 +163,7 @@
"queries": "Sorgular", "queries": "Sorgular",
"blocked": "Engellenen", "blocked": "Engellenen",
"gravity": "Yer Çekimi", "gravity": "Yer Çekimi",
"blocked_percent": "Blocked %" "blocked_percent": "Engellenen %"
}, },
"adguard": { "adguard": {
"queries": "Sorgular", "queries": "Sorgular",
@ -235,15 +239,15 @@
"glances": { "glances": {
"cpu": "İşlemci", "cpu": "İşlemci",
"wait": "Lütfen bekleyiniz", "wait": "Lütfen bekleyiniz",
"temp": "TEMP", "temp": "Sıcaklık",
"uptime": "UP", "uptime": "Çalışma Süresi",
"days": "d", "days": "Gün",
"hours": "h", "hours": "Saat",
"load": "Load", "load": "Yük",
"warn": "Warn", "warn": "Uyarı",
"total": "Total", "total": "Toplam",
"free": "Free", "free": "Boş",
"used": "Used" "used": "Kullanım"
}, },
"changedetectionio": { "changedetectionio": {
"totalObserved": "Toplam Gözlenen", "totalObserved": "Toplam Gözlenen",
@ -311,9 +315,9 @@
"bookmark": "Yer İmi", "bookmark": "Yer İmi",
"service": "Hizmet", "service": "Hizmet",
"search": "Ara", "search": "Ara",
"custom": "Custom", "custom": "Özel",
"visit": "Visit", "visit": "Ziyaret",
"url": "URL" "url": "Link"
}, },
"homebridge": { "homebridge": {
"available_update": "Sistem", "available_update": "Sistem",
@ -384,14 +388,14 @@
"deluge": { "deluge": {
"download": "İndir", "download": "İndir",
"upload": "Yükle", "upload": "Yükle",
"leech": "Leech", "leech": "Tüketici",
"seed": "Tohum" "seed": "Tohum"
}, },
"flood": { "flood": {
"download": "İndir", "download": "İndir",
"upload": "Yükle", "upload": "Yükle",
"leech": "Leech", "leech": "Tüketici",
"seed": "Tohum" "seed": "Sağlayıcı"
}, },
"tdarr": { "tdarr": {
"queue": "Sıra", "queue": "Sıra",
@ -421,7 +425,7 @@
"downloadstation": { "downloadstation": {
"download": "İndir", "download": "İndir",
"upload": "Yükle", "upload": "Yükle",
"leech": "Leech", "leech": "Tüketici",
"seed": "Tohum" "seed": "Tohum"
}, },
"mikrotik": { "mikrotik": {
@ -448,7 +452,7 @@
"layers": "Katmanlar" "layers": "Katmanlar"
}, },
"medusa": { "medusa": {
"wanted": "Wanted", "wanted": "Aranan",
"queued": "Kuyrukta", "queued": "Kuyrukta",
"series": "Seri" "series": "Seri"
}, },
@ -554,11 +558,11 @@
"targets_total": "Total Targets" "targets_total": "Total Targets"
}, },
"minecraft": { "minecraft": {
"players": "Players", "players": "Oyuncular",
"version": "Version", "version": "Versiyon",
"status": "Status", "status": "Durum",
"up": "Online", "up": "Çevrimiçi",
"down": "Offline" "down": "Çevrimdışı"
}, },
"ghostfolio": { "ghostfolio": {
"gross_percent_today": "Today", "gross_percent_today": "Today",
@ -577,40 +581,40 @@
"switches_on": "Switches On" "switches_on": "Switches On"
}, },
"freshrss": { "freshrss": {
"subscriptions": "Subscriptions", "subscriptions": "Abonelikler",
"unread": "Unread" "unread": "Okunmamış"
}, },
"channelsdvrserver": { "channelsdvrserver": {
"shows": "Shows", "shows": "Diziler",
"recordings": "Recordings", "recordings": "Kayıtlar",
"scheduled": "Scheduled", "scheduled": "Planlanmış",
"passes": "Passes" "passes": "Geçilenler"
}, },
"whatsupdocker": { "whatsupdocker": {
"monitoring": "Monitoring", "monitoring": "Monitoring",
"updates": "Updates" "updates": "Updates"
}, },
"tailscale": { "tailscale": {
"never": "Never", "never": "Asla",
"last_seen": "Last Seen", "last_seen": "Son Görülme",
"now": "Now", "now": "Şimdi",
"years": "{{number}}y", "years": "{{number}} Yıl",
"weeks": "{{number}}w", "weeks": "{{number}} Hafta",
"days": "{{number}}d", "days": "{{number}} Gün",
"hours": "{{number}}h", "hours": "{{number}} Saat",
"minutes": "{{number}}m", "minutes": "{{number}} Dakika",
"seconds": "{{number}}s", "seconds": "{{number}} Saniye",
"ago": "{{value}} Ago", "ago": "{{value}} Önce",
"address": "Address", "address": "Adres",
"expires": "Expires" "expires": "Geciken"
}, },
"qnap": { "qnap": {
"cpuUsage": "CPU Usage", "cpuUsage": "İşlemci Kullanımı",
"memUsage": "MEM Usage", "memUsage": "Bellek Kullanımı",
"systemTempC": "System Temp", "systemTempC": "Sistem Sıcaklığı",
"poolUsage": "Pool Usage", "poolUsage": "Havuz Kullanımı",
"volumeUsage": "Volume Usage", "volumeUsage": "Alan Kullanımı",
"invalid": "Invalid" "invalid": "Geçersiz"
}, },
"pfsense": { "pfsense": {
"load": "Load Avg", "load": "Load Avg",
@ -623,22 +627,28 @@
"wanIP": "WAN IP" "wanIP": "WAN IP"
}, },
"caddy": { "caddy": {
"upstreams": "Upstreams", "upstreams": "Akış",
"requests": "Current requests", "requests": "Anlık İstekler",
"requests_failed": "Failed requests" "requests_failed": "Başarısız İstekler"
}, },
"evcc": { "evcc": {
"pv_power": "Production", "pv_power": "Üretim",
"battery_soc": "Battery", "battery_soc": "Batarya",
"grid_power": "Grid", "grid_power": "Güç",
"home_power": "Consumption", "home_power": "Tüketim",
"charge_power": "Charger", "charge_power": "Şarj",
"watt_hour": "Wh" "watt_hour": "Watt/Saat"
}, },
"pialert": { "pialert": {
"total": "Total", "total": "Toplam",
"connected": "Connected", "connected": "Bağlandı",
"new_devices": "New Devices", "new_devices": "Yeni Cihazlar",
"down_alerts": "Down Alerts" "down_alerts": "Düşme Uyarıları"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -232,18 +232,22 @@
"sonarr": { "sonarr": {
"wanted": "Розшукується", "wanted": "Розшукується",
"queued": "У черзі", "queued": "У черзі",
"series": "Серії" "series": "Серії",
"queue": "Черга",
"unknown": "Невідомо"
}, },
"radarr": { "radarr": {
"wanted": "Розшукується", "wanted": "Розшукується",
"missing": "Відсутній", "missing": "Відсутній",
"queued": "У черзі", "queued": "У черзі",
"movies": "Фільми" "movies": "Фільми",
"queue": "Черга",
"unknown": "Невідомо"
}, },
"lidarr": { "lidarr": {
"wanted": "Розшукується", "wanted": "Розшукується",
"queued": "У черзі", "queued": "У черзі",
"albums": "Альбоми" "artists": "Виконавці"
}, },
"traefik": { "traefik": {
"middleware": "Проміжне програмне забезпечення", "middleware": "Проміжне програмне забезпечення",
@ -640,5 +644,11 @@
"connected": "Підключено", "connected": "Підключено",
"new_devices": "Нові пристрої", "new_devices": "Нові пристрої",
"down_alerts": "Сповіщення про збій" "down_alerts": "Сповіщення про збій"
},
"jdownloader": {
"downloadCount": "Всього в черзі",
"downloadSpeed": "Швидкість завантаження",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"series": "Series" "series": "Series",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"movies": "Phim", "movies": "Phim",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "Đang tìm", "wanted": "Đang tìm",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "Wanted", "wanted": "Wanted",
"queued": "Queued", "queued": "Queued",
"albums": "Albums" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "Queries", "queries": "Queries",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -94,18 +94,22 @@
"sonarr": { "sonarr": {
"wanted": "想睇", "wanted": "想睇",
"queued": "排緊隊", "queued": "排緊隊",
"series": "電視劇" "series": "電視劇",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "想睇", "wanted": "想睇",
"queued": "排緊隊", "queued": "排緊隊",
"movies": "電影", "movies": "電影",
"missing": "Missing" "missing": "Missing",
"queue": "Queue",
"unknown": "Unknown"
}, },
"lidarr": { "lidarr": {
"wanted": "想睇", "wanted": "想睇",
"queued": "排緊隊", "queued": "排緊隊",
"albums": "專輯" "artists": "Artists"
}, },
"readarr": { "readarr": {
"wanted": "想睇", "wanted": "想睇",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -66,13 +66,17 @@
"sonarr": { "sonarr": {
"wanted": "想看", "wanted": "想看",
"queued": "排队", "queued": "排队",
"series": "系列" "series": "系列",
"queue": "Queue",
"unknown": "Unknown"
}, },
"radarr": { "radarr": {
"wanted": "想看", "wanted": "想看",
"queued": "队列", "queued": "队列",
"movies": "电影", "movies": "电影",
"missing": "丢失" "missing": "丢失",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "订阅", "wanted": "订阅",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "订阅", "wanted": "订阅",
"queued": "队列", "queued": "队列",
"albums": "相册" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "查询", "queries": "查询",
@ -640,5 +644,11 @@
"connected": "Connected", "connected": "Connected",
"new_devices": "New Devices", "new_devices": "New Devices",
"down_alerts": "Down Alerts" "down_alerts": "Down Alerts"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -88,12 +88,16 @@
"movies": "電影", "movies": "電影",
"wanted": "關注中", "wanted": "關注中",
"queued": "已加入佇列", "queued": "已加入佇列",
"missing": "缺少" "missing": "缺少",
"queue": "Queue",
"unknown": "Unknown"
}, },
"sonarr": { "sonarr": {
"wanted": "關注中", "wanted": "關注中",
"queued": "已加入佇列", "queued": "已加入佇列",
"series": "影集" "series": "影集",
"queue": "Queue",
"unknown": "Unknown"
}, },
"readarr": { "readarr": {
"wanted": "關注中", "wanted": "關注中",
@ -173,7 +177,7 @@
"lidarr": { "lidarr": {
"wanted": "關注中", "wanted": "關注中",
"queued": "已加入佇列", "queued": "已加入佇列",
"albums": "專輯" "artists": "Artists"
}, },
"adguard": { "adguard": {
"queries": "查詢", "queries": "查詢",
@ -640,5 +644,11 @@
"connected": "已連線", "connected": "已連線",
"new_devices": "新裝置", "new_devices": "新裝置",
"down_alerts": "離線警告" "down_alerts": "離線警告"
},
"jdownloader": {
"downloadCount": "Queue Count",
"downloadSpeed": "Download Speed",
"downloadBytesRemaining": "Remaining",
"downloadTotalBytes": "Size"
} }
} }

View File

@ -3,7 +3,7 @@ import classNames from "classnames";
import List from "components/services/list"; import List from "components/services/list";
import ResolvedIcon from "components/resolvedicon"; import ResolvedIcon from "components/resolvedicon";
export default function ServicesGroup({ services, layout, fiveColumns }) { export default function ServicesGroup({ group, services, layout, fiveColumns }) {
return ( return (
<div <div
key={services.name} key={services.name}
@ -21,7 +21,7 @@ export default function ServicesGroup({ services, layout, fiveColumns }) {
} }
<h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">{services.name}</h2> <h2 className="text-theme-800 dark:text-theme-300 text-xl font-medium">{services.name}</h2>
</div> </div>
<List services={services.services} layout={layout} /> <List group={group} services={services.services} layout={layout} />
</div> </div>
); );
} }

View File

@ -11,7 +11,7 @@ import Kubernetes from "widgets/kubernetes/component";
import { SettingsContext } from "utils/contexts/settings"; import { SettingsContext } from "utils/contexts/settings";
import ResolvedIcon from "components/resolvedicon"; import ResolvedIcon from "components/resolvedicon";
export default function Item({ service }) { export default function Item({ service, group }) {
const hasLink = service.href && service.href !== "#"; const hasLink = service.href && service.href !== "#";
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const showStats = (service.showStats === false) ? false : settings.showStats; const showStats = (service.showStats === false) ? false : settings.showStats;
@ -77,7 +77,7 @@ export default function Item({ service }) {
<div className="absolute top-0 right-0 w-1/2 flex flex-row justify-end gap-2 mr-2"> <div className="absolute top-0 right-0 w-1/2 flex flex-row justify-end gap-2 mr-2">
{service.ping && ( {service.ping && (
<div className="flex-shrink-0 flex items-center justify-center cursor-pointer"> <div className="flex-shrink-0 flex items-center justify-center cursor-pointer">
<Ping service={service} /> <Ping group={group} service={service.name} />
<span className="sr-only">Ping status</span> <span className="sr-only">Ping status</span>
</div> </div>
)} )}

View File

@ -14,7 +14,7 @@ const columnMap = [
"grid-cols-1 md:grid-cols-2 lg:grid-cols-8", "grid-cols-1 md:grid-cols-2 lg:grid-cols-8",
]; ];
export default function List({ services, layout }) { export default function List({ group, services, layout }) {
return ( return (
<ul <ul
className={classNames( className={classNames(
@ -23,7 +23,7 @@ export default function List({ services, layout }) {
)} )}
> >
{services.map((service) => ( {services.map((service) => (
<Item key={service.container ?? service.app ?? service.name} service={service} /> <Item key={service.container ?? service.app ?? service.name} service={service} group={group} />
))} ))}
</ul> </ul>
); );

View File

@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
export default function Ping({ service }) { export default function Ping({ group, service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ping: service.ping}).toString()}`, { const { data, error } = useSWR(`/api/ping?${new URLSearchParams({ group, service }).toString()}`, {
refreshInterval: 30000 refreshInterval: 30000
}); });
@ -23,7 +23,7 @@ export default function Ping({ service }) {
); );
} }
const statusText = `${service.ping}: HTTP status ${data.status}`; const statusText = `${service}: HTTP status ${data.status}`;
if (data.status > 403) { if (data.status > 403) {
return ( return (

View File

@ -15,7 +15,9 @@ export default function Container({ error = false, children, service }) {
return <Error service={service} error={error} /> return <Error service={service} error={error} />
} }
let visibleChildren = children; const childrenArray = Array.isArray(children) ? children : [children];
let visibleChildren = childrenArray;
const fields = service?.widget?.fields; const fields = service?.widget?.fields;
const type = service?.widget?.type; const type = service?.widget?.type;
if (fields && type) { if (fields && type) {
@ -24,7 +26,7 @@ export default function Container({ error = false, children, service }) {
// fields: [ "resources.cpu", "resources.mem", "field"] // fields: [ "resources.cpu", "resources.mem", "field"]
// or even // or even
// fields: [ "resources.cpu", "widget_type.field" ] // fields: [ "resources.cpu", "widget_type.field" ]
visibleChildren = children?.filter(child => fields.some(field => { visibleChildren = childrenArray?.filter(child => fields.some(field => {
let fullField = field; let fullField = field;
if (!field.includes(".")) { if (!field.includes(".")) {
fullField = `${type}.${field}`; fullField = `${type}.${field}`;

View File

@ -9,10 +9,12 @@ function displayData(data) {
return (data.type === 'Buffer') ? Buffer.from(data).toString() : JSON.stringify(data, 4); return (data.type === 'Buffer') ? Buffer.from(data).toString() : JSON.stringify(data, 4);
} }
export default function Error({ error: err }) { export default function Error({ error }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { error } = err?.data ?? { error: err }; if (error?.data?.error) {
error = error.data.error; // eslint-disable-line no-param-reassign
}
return ( return (
<details className="px-1 pb-1"> <details className="px-1 pb-1">

View File

@ -1,6 +1,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Container from "../widget/container";
import Raw from "../widget/raw";
const textSizes = { const textSizes = {
"4xl": "text-4xl", "4xl": "text-4xl",
"3xl": "text-3xl", "3xl": "text-3xl",
@ -17,7 +20,7 @@ export default function DateTime({ options }) {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [date, setDate] = useState(""); const [date, setDate] = useState("");
const dateLocale = locale ?? i18n.language; const dateLocale = locale ?? i18n.language;
useEffect(() => { useEffect(() => {
const dateFormat = new Intl.DateTimeFormat(dateLocale, { ...format }); const dateFormat = new Intl.DateTimeFormat(dateLocale, { ...format });
const interval = setInterval(() => { const interval = setInterval(() => {
@ -27,12 +30,14 @@ export default function DateTime({ options }) {
}, [date, setDate, dateLocale, format]); }, [date, setDate, dateLocale, format]);
return ( return (
<div className="flex flex-col justify-center first:ml-0 ml-4"> <Container options={options}>
<div className="flex flex-row items-center grow justify-end"> <Raw>
<span className={`text-theme-800 dark:text-theme-200 tabular-nums ${textSizes[textSize || "lg"]}`}> <div className="flex flex-row items-center grow justify-end">
{date} <span className={`text-theme-800 dark:text-theme-200 tabular-nums ${textSizes[textSize || "lg"]}`}>
</span> {date}
</div> </span>
</div> </div>
</Raw>
</Container>
); );
} }

View File

@ -1,11 +1,13 @@
import useSWR from "swr"; import useSWR from "swr";
import { useContext } from "react"; import { useContext } from "react";
import { BiError } from "react-icons/bi";
import { FaMemory, FaRegClock, FaThermometerHalf } from "react-icons/fa"; import { FaMemory, FaRegClock, FaThermometerHalf } from "react-icons/fa";
import { FiCpu, FiHardDrive } from "react-icons/fi"; import { FiCpu, FiHardDrive } from "react-icons/fi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "../resources/usage-bar"; import Error from "../widget/error";
import Resource from "../widget/resource";
import Resources from "../widget/resources";
import WidgetLabel from "../widget/widget_label";
import { SettingsContext } from "utils/contexts/settings"; import { SettingsContext } from "utils/contexts/settings";
@ -26,52 +28,19 @@ export default function Widget({ options }) {
); );
if (error || data?.error) { if (error || data?.error) {
return ( return <Error options={options} />
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-row items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
</div>
</div>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Resources options={options}>
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap ml-4"> <Resource icon={FiCpu} label={t("glances.wait")} percentage="0" />
<div className="flex flex-row self-center flex-wrap justify-between"> <Resource icon={FaMemory} label={t("glances.wait")} percentage="0" />
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> { options.cputemp && <Resource icon={FaThermometerHalf} label={t("glances.wait")} percentage="0" /> }
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" /> { options.disk && !Array.isArray(options.disk) && <Resource key={options.disk} icon={FiHardDrive} label={t("glances.wait")} percentage="0" /> }
<div className="flex flex-col ml-3 text-left min-w-[85px]"> { options.disk && Array.isArray(options.disk) && options.disk.map((disk) => <Resource key={disk.mnt_point} icon={FiHardDrive} label={t("glances.wait")} percentage="0" /> )}
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> { options.uptime && <Resource icon={FaRegClock} label={t("glances.wait")} percentage="0" /> }
<div className="pl-0.5 text-xs"> { options.label && <WidgetLabel label={options.label} /> }
{t("glances.wait")} </Resources>;
</div>
</div>
<UsageBar percent="0" />
</div>
</div>
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 text-xs">
{t("glances.wait")}
</div>
</div>
<UsageBar percent="0" />
</div>
</div>
</div>
{options.label && (
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
)}
</div>
);
} }
const unit = options.units === "imperial" ? "fahrenheit" : "celsius"; const unit = options.units === "imperial" ? "fahrenheit" : "celsius";
@ -101,131 +70,84 @@ export default function Widget({ options }) {
} }
return ( return (
<a href={options.url} target={settings.target ?? "_blank"} className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap"> <Resources options={options} target={settings.target ?? "_blank"}>
<div className="flex flex-row self-center flex-wrap justify-between"> <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> icon={FiCpu}
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value={t("common.number", {
<div className="flex flex-col ml-3 text-left min-w-[85px]"> value: data.cpu.total,
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> style: "unit",
<div className="pl-0.5"> unit: "percent",
{t("common.number", { maximumFractionDigits: 0,
value: data.cpu.total, })}
style: "unit", label={t("glances.cpu")}
unit: "percent", expandedValue={t("common.number", {
maximumFractionDigits: 0, value: data.load.min15,
})} style: "unit",
</div> unit: "percent",
<div className="pr-1">{t("glances.cpu")}</div> maximumFractionDigits: 0
</div> })}
{options.expanded && ( expandedLabel={t("glances.load")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> percentage={data.cpu.total}
<div className="pl-0.5 pr-1"> expanded={options.expanded}
{t("common.number", { />
value: data.load.min15, <Resource
style: "unit", icon={FaMemory}
unit: "percent", value={t("common.bytes", {
maximumFractionDigits: 0, value: data.mem.free,
})} maximumFractionDigits: 1,
</div> binary: true,
<div className="pr-1">{t("glances.load")}</div> })}
</span> label={t("glances.free")}
)} expandedValue={t("common.bytes", {
<UsageBar percent={data.cpu.total} /> value: data.mem.total,
</div> maximumFractionDigits: 1,
</div> binary: true,
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> })}
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" /> expandedLabel={t("glances.total")}
<div className="flex flex-col ml-3 text-left min-w-[85px]"> percentage={data.mem.percent}
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expanded={options.expanded}
<div className="pl-0.5"> />
{t("common.bytes", { {disks.map((disk) => (
value: data.mem.free, <Resource key={disk.mnt_point}
maximumFractionDigits: 1, icon={FiHardDrive}
binary: true, value={t("common.bytes", { value: disk.free })}
})} label={t("glances.free")}
</div> expandedValue={t("common.bytes", { value: disk.size })}
<div className="pr-1">{t("glances.free")}</div> expandedLabel={t("glances.total")}
</div> percentage={disk.percent}
{options.expanded && ( expanded={options.expanded}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> />
<div className="pl-0.5 pr-1"> ))}
{t("common.bytes", { {options.cputemp && mainTemp > 0 &&
value: data.mem.total, <Resource
maximumFractionDigits: 1, icon={FaThermometerHalf}
binary: true, value={t("common.number", {
})} value: mainTemp,
</div> maximumFractionDigits: 1,
<div className="pr-1">{t("glances.total")}</div> style: "unit",
</span> unit
)} })}
<UsageBar percent={data.mem.percent} /> label={t("glances.temp")}
</div> expandedValue={t("common.number", {
</div> value: maxTemp,
{disks.map((disk) => ( maximumFractionDigits: 1,
<div key={disk.mnt_point} className="flex-none flex flex-row items-center mr-3 py-1.5"> style: "unit",
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" /> unit
<div className="flex flex-col ml-3 text-left min-w-[85px]"> })}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedLabel={t("glances.warn")}
<div className="pl-0.5">{t("common.bytes", { value: disk.free })}</div> percentage={tempPercent}
<div className="pr-1">{t("glances.free")}</div> expanded={options.expanded}
</span> />
{options.expanded && ( }
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> {options.uptime && data.uptime &&
<div className="pl-0.5 pr-1">{t("common.bytes", { value: disk.size })}</div> <Resource
<div className="pr-1">{t("glances.total")}</div> icon={FaRegClock}
</span> value={data.uptime.replace(" days,", t("glances.days")).replace(/:\d\d:\d\d$/g, t("glances.hours"))}
)} label={t("glances.uptime")}
<UsageBar percent={disk.percent} /> percentage={Math.round((new Date().getSeconds() / 60) * 100).toString()}
</div> />
</div>))} }
{options.cputemp && mainTemp > 0 && {options.label && <WidgetLabel label={options.label} />}
(<div className="flex-none flex flex-row items-center mr-3 py-1.5"> </Resources>
<FaThermometerHalf className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{t("common.number", {
value: mainTemp,
maximumFractionDigits: 1,
style: "unit",
unit
})}
</div>
<div className="pr-1">{t("glances.temp")}</div>
</span>
{options.expanded && (
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">
{t("common.number", {
value: maxTemp,
maximumFractionDigits: 1,
style: "unit",
unit
})}
</div>
<div className="pr-1">{t("glances.warn")}</div>
</span>
)}
<UsageBar percent={tempPercent} />
</div>
</div>)}
{options.uptime && data.uptime &&
(<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FaRegClock className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{data.uptime.replace(" days,", t("glances.days")).replace(/:\d\d:\d\d$/g, t("glances.hours"))}
</div>
<div className="pr-1">{t("glances.uptime")}</div>
</span>
<UsageBar percent={Math.round((new Date().getSeconds() / 60) * 100)} />
</div>
</div>)}
</div>
{options.label && (
<div className="pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
)}
</a>
); );
} }

View File

@ -1,3 +1,6 @@
import Container from "../widget/container";
import Raw from "../widget/raw";
const textSizes = { const textSizes = {
"4xl": "text-4xl", "4xl": "text-4xl",
"3xl": "text-3xl", "3xl": "text-3xl",
@ -11,12 +14,12 @@ const textSizes = {
export default function Greeting({ options }) { export default function Greeting({ options }) {
if (options.text) { if (options.text) {
return ( return <Container options={options}>
<div className="flex flex-row items-center justify-start"> <Raw>
<span className={`text-theme-800 dark:text-theme-200 mr-3 ${textSizes[options.text_size || "xl"]}`}> <span className={`text-theme-800 dark:text-theme-200 mr-3 ${textSizes[options.text_size || "xl"]}`}>
{options.text} {options.text}
</span> </span>
</div> </Raw>
); </Container>;
} }
} }

View File

@ -1,12 +1,15 @@
import useSWR from "swr"; import useSWR from "swr";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Error from "../widget/error";
import Container from "../widget/container";
import Raw from "../widget/raw";
import Node from "./node"; import Node from "./node";
export default function Widget({ options }) { export default function Widget({ options }) {
const { cluster, nodes } = options; const { cluster, nodes } = options;
const { t, i18n } = useTranslation(); const { i18n } = useTranslation();
const defaultData = { const defaultData = {
cpu: { cpu: {
@ -18,7 +21,7 @@ export default function Widget({ options }) {
used: 0, used: 0,
total: 0, total: 0,
free: 0, free: 0,
precent: 0 percent: 0
} }
}; };
@ -29,23 +32,12 @@ export default function Widget({ options }) {
); );
if (error || data?.error) { if (error || data?.error) {
return ( return <Error options={options} />
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-row items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
</div>
</div>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Container options={options}>
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap"> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{cluster.show && {cluster.show &&
<Node type="cluster" key="cluster" options={options.cluster} data={defaultData} /> <Node type="cluster" key="cluster" options={options.cluster} data={defaultData} />
@ -54,12 +46,12 @@ export default function Widget({ options }) {
<Node type="node" key="nodes" options={options.nodes} data={defaultData} /> <Node type="node" key="nodes" options={options.nodes} data={defaultData} />
} }
</div> </div>
</div> </Raw>
); </Container>;
} }
return ( return <Container options={options}>
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap"> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{cluster.show && {cluster.show &&
<Node key="cluster" type="cluster" options={options.cluster} data={data.cluster} /> <Node key="cluster" type="cluster" options={options.cluster} data={data.cluster} />
@ -69,6 +61,6 @@ export default function Widget({ options }) {
<Node key={node.name} type="node" options={options.nodes} data={node} />) <Node key={node.name} type="node" options={options.nodes} data={node} />)
} }
</div> </div>
</div> </Raw>
); </Container>;
} }

View File

@ -3,8 +3,7 @@ import { FiAlertTriangle, FiCpu, FiServer } from "react-icons/fi";
import { SiKubernetes } from "react-icons/si"; import { SiKubernetes } from "react-icons/si";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar"; import UsageBar from "../resources/usage-bar";
export default function Node({ type, options, data }) { export default function Node({ type, options, data }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -29,7 +28,7 @@ export default function Node({ type, options, data }) {
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5"> <div className="pl-0.5">
{t("common.number", { {t("common.number", {
value: data.cpu.percent, value: data?.cpu?.percent ?? 0,
style: "unit", style: "unit",
unit: "percent", unit: "percent",
maximumFractionDigits: 0 maximumFractionDigits: 0
@ -37,18 +36,18 @@ export default function Node({ type, options, data }) {
</div> </div>
<FiCpu className="text-theme-800 dark:text-theme-200 w-3 h-3" /> <FiCpu className="text-theme-800 dark:text-theme-200 w-3 h-3" />
</div> </div>
<UsageBar percent={data.cpu.percent} /> <UsageBar percent={data?.cpu?.percent ?? 0} />
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5"> <div className="pl-0.5">
{t("common.bytes", { {t("common.bytes", {
value: data.memory.free, value: data?.memory?.free ?? 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
binary: true binary: true
})} })}
</div> </div>
<FaMemory className="text-theme-800 dark:text-theme-200 w-3 h-3" /> <FaMemory className="text-theme-800 dark:text-theme-200 w-3 h-3" />
</div> </div>
<UsageBar percent={data.memory.percent} /> <UsageBar percent={data?.memory?.percent} />
{options.showLabel && ( {options.showLabel && (
<div className="pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{type === "cluster" ? options.label : data.name}</div> <div className="pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{type === "cluster" ? options.label : data.name}</div>
)} )}

View File

@ -1,12 +0,0 @@
export default function UsageBar({ percent }) {
return (
<div className="mt-0.5 w-full bg-theme-800/30 rounded-full h-1 dark:bg-theme-200/20">
<div
className="bg-theme-800/70 h-1 rounded-full dark:bg-theme-200/50 transition-all duration-1000"
style={{
width: `${percent}%`,
}}
/>
</div>
);
}

View File

@ -1,9 +1,13 @@
import Container from "../widget/container";
import Raw from "../widget/raw";
import ResolvedIcon from "components/resolvedicon" import ResolvedIcon from "components/resolvedicon"
export default function Logo({ options }) { export default function Logo({ options }) {
return ( return (
<div className="w-12 h-12 flex flex-row items-center align-middle mr-3 self-center"> <Container options={options}>
{options.icon ? <Raw>
{options.icon ?
<ResolvedIcon icon={options.icon} width={48} height={48} /> : <ResolvedIcon icon={options.icon} width={48} height={48} /> :
// fallback to homepage logo // fallback to homepage logo
<svg <svg
@ -57,6 +61,7 @@ export default function Logo({ options }) {
</g> </g>
</svg> </svg>
} }
</div> </Raw>
</Container>
) )
} }

View File

@ -1,37 +1,31 @@
import useSWR from "swr"; import useSWR from "swr";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import Error from "../widget/error";
import Container from "../widget/container";
import Raw from "../widget/raw";
import Node from "./node"; import Node from "./node";
export default function Longhorn({ options }) { export default function Longhorn({ options }) {
const { expanded, total, labels, include, nodes } = options; const { expanded, total, labels, include, nodes } = options;
const { t } = useTranslation();
const { data, error } = useSWR(`/api/widgets/longhorn`, { const { data, error } = useSWR(`/api/widgets/longhorn`, {
refreshInterval: 1500 refreshInterval: 1500
}); });
if (error || data?.error) { if (error || data?.error) {
return ( return <Error options={options} />
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Container options={options}>
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap"> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between" /> <div className="flex flex-row self-center flex-wrap justify-between" />
</div> </Raw>
); </Container>;
} }
return ( return <Container options={options}>
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap"> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{data.nodes {data.nodes
.filter((node) => { .filter((node) => {
@ -52,6 +46,6 @@ export default function Longhorn({ options }) {
</div> </div>
)} )}
</div> </div>
</div> </Raw>
); </Container>;
} }

View File

@ -1,32 +1,20 @@
import { FiHardDrive } from "react-icons/fi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { FaThermometerHalf } from "react-icons/fa";
import UsageBar from "../resources/usage-bar"; import Resource from "../widget/resource";
import WidgetLabel from "../widget/widget_label";
export default function Node({ data, expanded, labels }) { export default function Node({ data, expanded, labels }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return <Resource
<> icon={FaThermometerHalf}
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> value={t("common.bytes", { value: data.node.available })}
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" /> label={t("resources.free")}
<div className="flex flex-col ml-3 text-left min-w-[85px]"> expandedValue={t("common.bytes", { value: data.node.maximum })}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedLabel={t("resources.total")}
<div className="pl-0.5">{t("common.bytes", { value: data.node.available })}</div> percentage={Math.round(((data.node.maximum - data.node.available) / data.node.maximum) * 100)}
<div className="pr-1">{t("resources.free")}</div> expanded={expanded}
</span> >{ labels && <WidgetLabel label={data.node.id} /> }
{expanded && ( </Resource>
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{t("common.bytes", { value: data.node.maximum })}</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={Math.round(((data.node.maximum - data.node.available) / data.node.maximum) * 100)} />
</div>
</div>
{labels && (
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{data.node.id}</div>
)}
</>
);
} }

View File

@ -1,10 +1,16 @@
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react"; import { useState } from "react";
import { BiError } from "react-icons/bi";
import { WiCloudDown } from "react-icons/wi"; import { WiCloudDown } from "react-icons/wi";
import { MdLocationDisabled, MdLocationSearching } from "react-icons/md"; import { MdLocationDisabled, MdLocationSearching } from "react-icons/md";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Error from "../widget/error";
import Container from "../widget/container";
import ContainerButton from "../widget/container_button";
import WidgetIcon from "../widget/widget_icon";
import PrimaryText from "../widget/primary_text";
import SecondaryText from "../widget/secondary_text";
import Icon from "./icon"; import Icon from "./icon";
function Widget({ options }) { function Widget({ options }) {
@ -15,60 +21,35 @@ function Widget({ options }) {
); );
if (error || data?.error) { if (error || data?.error) {
return ( return <Error options={options} />
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Container options={options}>
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2"> <PrimaryText>{t("weather.updating")}</PrimaryText>
<div className="flex flex-row items-center justify-end"> <SecondaryText>{t("weather.wait")}</SecondaryText>
<div className="flex flex-col items-center"> <WidgetIcon icon={WiCloudDown} size="l" />
<WiCloudDown className="w-8 h-8 text-theme-800 dark:text-theme-200" /> </Container>;
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.updating")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.wait")}</span>
</div>
</div>
</div>
);
} }
const unit = options.units === "metric" ? "celsius" : "fahrenheit"; const unit = options.units === "metric" ? "celsius" : "fahrenheit";
const timeOfDay = data.current_weather.time > data.daily.sunrise[0] && data.current_weather.time < data.daily.sunset[0] ? "day" : "night"; const weatherInfo = {
condition: data.current_weather.weathercode,
timeOfDay: data.current_weather.time > data.daily.sunrise[0] && data.current_weather.time < data.daily.sunset[0] ? "day" : "night"
};
return ( return <Container options={options}>
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2"> <PrimaryText>
<div className="flex flex-row items-center justify-end"> {options.label && `${options.label}, `}
<div className="flex flex-col items-center"> {t("common.number", {
<Icon condition={data.current_weather.weathercode} timeOfDay={timeOfDay} /> value: data.current_weather.temperature,
</div> style: "unit",
<div className="flex flex-col ml-3 text-left"> unit,
<span className="text-theme-800 dark:text-theme-200 text-sm"> })}
{options.label && `${options.label}, `} </PrimaryText>
{t("common.number", { <SecondaryText>{t(`wmo.${data.current_weather.weathercode}-${weatherInfo.timeOfDay}`)}</SecondaryText>
value: data.current_weather.temperature, <WidgetIcon icon={Icon} size="xl" weatherInfo={weatherInfo} />
style: "unit", </Container>;
unit,
})}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t(`wmo.${data.current_weather.weathercode}-${timeOfDay}`)}</span>
</div>
</div>
</div>
);
} }
export default function OpenMeteo({ options }) { export default function OpenMeteo({ options }) {
@ -103,27 +84,11 @@ export default function OpenMeteo({ options }) {
// if (!requesting && !location) requestLocation(); // if (!requesting && !location) requestLocation();
if (!location) { if (!location) {
return ( return <ContainerButton options={options} callback={requestLocation} >
<button <PrimaryText>{t("weather.current")}</PrimaryText>
type="button" <SecondaryText>{t("weather.allow")}</SecondaryText>
onClick={() => requestLocation()} <WidgetIcon icon={ requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse />
className="flex flex-col justify-center first:ml-0 ml-4 mr-2" </ContainerButton>;
>
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
{requesting ? (
<MdLocationSearching className="w-6 h-6 text-theme-800 dark:text-theme-200 animate-pulse" />
) : (
<MdLocationDisabled className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.current")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.allow")}</span>
</div>
</div>
</button>
);
} }
return <Widget options={{ ...location, ...options }} />; return <Widget options={{ ...location, ...options }} />;

View File

@ -1,12 +1,19 @@
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react"; import { useState } from "react";
import { BiError } from "react-icons/bi";
import { WiCloudDown } from "react-icons/wi"; import { WiCloudDown } from "react-icons/wi";
import { MdLocationDisabled, MdLocationSearching } from "react-icons/md"; import { MdLocationDisabled, MdLocationSearching } from "react-icons/md";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Error from "../widget/error";
import Container from "../widget/container";
import ContainerButton from "../widget/container_button";
import PrimaryText from "../widget/primary_text";
import SecondaryText from "../widget/secondary_text";
import WidgetIcon from "../widget/widget_icon";
import Icon from "./icon"; import Icon from "./icon";
function Widget({ options }) { function Widget({ options }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -15,58 +22,29 @@ function Widget({ options }) {
); );
if (error || data?.cod === 401 || data?.error) { if (error || data?.cod === 401 || data?.error) {
return ( return <Error options={options} />
<div className="flex flex-col justify-center first:ml-auto ml-4 mr-2">
<div className="flex flex-row items-center justify-end">
<div className="hidden sm:flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Container options={options}>
<div className="flex flex-col justify-center first:ml-auto ml-4 mr-2"> <PrimaryText>{t("weather.updating")}</PrimaryText>
<div className="flex flex-row items-center justify-end"> <SecondaryText>{t("weather.wait")}</SecondaryText>
<div className="hidden sm:flex flex-col items-center"> <WidgetIcon icon={WiCloudDown} size="l" />
<WiCloudDown className="w-8 h-8 text-theme-800 dark:text-theme-200" /> </Container>;
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.updating")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.wait")}</span>
</div>
</div>
</div>
);
} }
const unit = options.units === "metric" ? "celsius" : "fahrenheit"; const unit = options.units === "metric" ? "celsius" : "fahrenheit";
return ( const weatherInfo = {
<div className="flex flex-col justify-center first:ml-auto ml-2 mr-2"> condition: data.weather[0].id,
<div className="flex flex-row items-center justify-end"> timeOfDay: data.dt > data.sys.sunrise && data.dt < data.sys.sunset ? "day" : "night"
<div className="hidden sm:flex flex-col items-center"> };
<Icon
condition={data.weather[0].id} return <Container options={options}>
timeOfDay={data.dt > data.sys.sunrise && data.dt < data.sys.sunset ? "day" : "night"} <PrimaryText>{options.label && `${options.label}, ` }{t("common.number", { value: data.main.temp, style: "unit", unit })}</PrimaryText>
/> <SecondaryText>{data.weather[0].description}</SecondaryText>
</div> <WidgetIcon icon={Icon} size="xl" weatherInfo={weatherInfo} />
<div className="flex flex-col ml-3 text-left"> </Container>;
<span className="text-theme-800 dark:text-theme-200 text-sm">
{options.label && `${options.label}, `}
{t("common.number", { value: data.main.temp, style: "unit", unit })}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{data.weather[0].description}</span>
</div>
</div>
</div>
);
} }
export default function OpenWeatherMap({ options }) { export default function OpenWeatherMap({ options }) {
@ -98,30 +76,12 @@ export default function OpenWeatherMap({ options }) {
} }
}; };
// if (!requesting && !location) requestLocation();
if (!location) { if (!location) {
return ( return <ContainerButton options={options} callback={requestLocation} >
<button <PrimaryText>{t("weather.current")}</PrimaryText>
type="button" <SecondaryText>{t("weather.allow")}</SecondaryText>
onClick={() => requestLocation()} <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse />
className="flex flex-col justify-center first:ml-auto ml-4 mr-2" </ContainerButton>;
>
<div className="flex flex-row items-center justify-end">
<div className="hidden sm:flex flex-col items-center">
{requesting ? (
<MdLocationSearching className="w-6 h-6 text-theme-800 dark:text-theme-200 animate-pulse" />
) : (
<MdLocationDisabled className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.current")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.allow")}</span>
</div>
</div>
</button>
);
} }
return <Widget options={{ ...location, ...options }} />; return <Widget options={{ ...location, ...options }} />;

View File

@ -0,0 +1,18 @@
export default function QueueEntry({ title, activity, timeLeft, progress}) {
return (
<div className="text-theme-700 dark:text-theme-200 relative h-5 rounded-md bg-theme-200/50 dark:bg-theme-900/20 m-1 px-1 flex">
<div
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0 -ml-1"
style={{
width: `${progress}%`,
}}
/>
<div className="text-xs z-10 self-center ml-2 relative h-4 grow mr-2">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden text-left">{title}</div>
</div>
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10 text-ellipsis overflow-hidden whitespace-nowrap">
{timeLeft ? `${activity} - ${timeLeft}` : activity}
</div>
</div>
);
}

View File

@ -1,9 +1,9 @@
import useSWR from "swr"; import useSWR from "swr";
import { FiCpu } from "react-icons/fi"; import { FiCpu } from "react-icons/fi";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar"; import Resource from "../widget/resource";
import Error from "../widget/error";
export default function Cpu({ expanded }) { export default function Cpu({ expanded }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -13,67 +13,29 @@ export default function Cpu({ expanded }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return ( return <Error />
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Resource icon={FiCpu} value="-" label={t("resources.cpu")} expandedValue="-"
<div className="flex-none flex flex-row items-center mr-3 py-1.5 animate-pulse"> expandedLabel={t("resources.load")} percentage="0" expanded={expanded} />
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">-</div>
<div className="pr-1">{t("resources.cpu")}</div>
</div>
{expanded && (
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">-</div>
<div className="pr-1">{t("resources.load")}</div>
</div>
)}
<UsageBar percent={0} />
</div>
</div>
);
} }
const percent = data.cpu.usage; return <Resource
icon={FiCpu}
return ( value={t("common.number", {
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> value: data.cpu.usage,
<FiCpu className="text-theme-800 dark:text-theme-200 w-5 h-5" /> style: "unit",
<div className="flex flex-col ml-3 text-left min-w-[85px]"> unit: "percent",
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> maximumFractionDigits: 0,
<div className="pl-0.5 pr-1"> })}
{t("common.number", { label={t("resources.cpu")}
value: data.cpu.usage, expandedValue={t("common.number", {
style: "unit", value: data.cpu.load,
unit: "percent", maximumFractionDigits: 2,
maximumFractionDigits: 0, })}
})} expandedLabel={t("resources.load")}
</div> percentage={data.cpu.usage}
<div className="pr-1">{t("resources.cpu")}</div> expanded={expanded}
</div> />
{expanded && (
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">
{t("common.number", {
value: data.cpu.load,
maximumFractionDigits: 2,
})}
</div>
<div className="pr-1">{t("resources.load")}</div>
</div>
)}
<UsageBar percent={percent} />
</div>
</div>
);
} }

View File

@ -1,9 +1,9 @@
import useSWR from "swr"; import useSWR from "swr";
import { FaThermometerHalf } from "react-icons/fa"; import { FaThermometerHalf } from "react-icons/fa";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar"; import Resource from "../widget/resource";
import Error from "../widget/error";
function convertToFahrenheit(t) { function convertToFahrenheit(t) {
return t * 9/5 + 32 return t * 9/5 + 32
@ -17,34 +17,18 @@ export default function CpuTemp({ expanded, units }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return ( return <Error />
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
} }
if (!data || !data.cputemp) { if (!data || !data.cputemp) {
return ( return <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5 animate-pulse"> icon={FaThermometerHalf}
<FaThermometerHalf className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value="-"
<div className="flex flex-col ml-3 text-left min-w-[85px]"> label={t("resources.temp")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedValue="-"
<div className="pl-0.5">-</div> expandedLabel={t("resources.max")}
<div className="pr-1">{t("resources.temp")}</div> expanded={expanded}
</span> />;
{expanded && (
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">-</div>
<div className="pr-1">{t("resources.max")}</div>
</span>
)}
</div>
</div>
);
} }
let mainTemp = data.cputemp.main; let mainTemp = data.cputemp.main;
@ -54,38 +38,24 @@ export default function CpuTemp({ expanded, units }) {
const unit = units === "imperial" ? "fahrenheit" : "celsius"; const unit = units === "imperial" ? "fahrenheit" : "celsius";
mainTemp = (unit === "celsius") ? mainTemp : convertToFahrenheit(mainTemp); mainTemp = (unit === "celsius") ? mainTemp : convertToFahrenheit(mainTemp);
const maxTemp = (unit === "celsius") ? data.cputemp.max : convertToFahrenheit(data.cputemp.max); const maxTemp = (unit === "celsius") ? data.cputemp.max : convertToFahrenheit(data.cputemp.max);
const percent = Math.round((mainTemp / maxTemp) * 100);
return ( return <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> icon={FaThermometerHalf}
<FaThermometerHalf className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value={t("common.number", {
<div className="flex flex-col ml-3 text-left min-w-[85px]"> value: mainTemp,
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> maximumFractionDigits: 1,
<div className="pl-0.5"> style: "unit",
{t("common.number", { unit
value: mainTemp, })}
maximumFractionDigits: 1, label={t("resources.temp")}
style: "unit", expandedValue={t("common.number", {
unit value: maxTemp,
})} maximumFractionDigits: 1,
</div> style: "unit",
<div className="pr-1">{t("resources.temp")}</div> unit
</span> })}
{expanded && ( expandedLabel={t("resources.max")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> percentage={Math.round((mainTemp / maxTemp) * 100)}
<div className="pl-0.5"> expanded={expanded}
{t("common.number", { />;
value: maxTemp,
maximumFractionDigits: 1,
style: "unit",
unit
})}
</div>
<div className="pr-1">{t("resources.max")}</div>
</span>
)}
<UsageBar percent={percent} />
</div>
</div>
);
} }

View File

@ -1,9 +1,9 @@
import useSWR from "swr"; import useSWR from "swr";
import { FiHardDrive } from "react-icons/fi"; import { FiHardDrive } from "react-icons/fi";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar"; import Resource from "../widget/resource";
import Error from "../widget/error";
export default function Disk({ options, expanded }) { export default function Disk({ options, expanded }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -13,56 +13,31 @@ export default function Disk({ options, expanded }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return ( return <Error options={options} />
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5 animate-pulse"> icon={FiHardDrive}
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value="-"
<div className="flex flex-col ml-3 text-left min-w-[85px]"> label={t("resources.free")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedValue="-"
<div className="pl-0.5 pr-1">-</div> expandedLabel={t("resources.total")}
<div className="pr-1">{t("resources.free")}</div> expanded={expanded}
</span> percentage="0"
{expanded && ( />;
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">-</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={0} />
</div>
</div>
);
} }
// data.drive.used not accurate? // data.drive.used not accurate?
const percent = Math.round(((data.drive.size - data.drive.available) / data.drive.size) * 100); const percent = Math.round(((data.drive.size - data.drive.available) / data.drive.size) * 100);
return ( return <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> icon={FiHardDrive}
<FiHardDrive className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value={t("common.bytes", { value: data.drive.available })}
<div className="flex flex-col ml-3 text-left min-w-[85px]"> label={t("resources.free")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedValue={t("common.bytes", { value: data.drive.size })}
<div className="pl-0.5 pr-1">{t("common.bytes", { value: data.drive.available })}</div> expandedLabel={t("resources.total")}
<div className="pr-1">{t("resources.free")}</div> percentage={percent}
</span> expanded={expanded}
{expanded && ( />;
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">{t("common.bytes", { value: data.drive.size })}</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={percent} />
</div>
</div>
);
} }

View File

@ -1,9 +1,9 @@
import useSWR from "swr"; import useSWR from "swr";
import { FaMemory } from "react-icons/fa"; import { FaMemory } from "react-icons/fa";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar"; import Resource from "../widget/resource";
import Error from "../widget/error";
export default function Memory({ expanded }) { export default function Memory({ expanded }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -13,63 +13,30 @@ export default function Memory({ expanded }) {
}); });
if (error || data?.error) { if (error || data?.error) {
return ( return <Error />
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5 animate-pulse"> icon={FaMemory}
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value="-"
<div className="flex flex-col ml-3 text-left min-w-[85px]"> label={t("resources.free")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedValue="-"
<div className="pl-0.5 pr-1">-</div> expandedLabel={t("resources.total")}
<div className="pr-1">{t("resources.free")}</div> expanded={expanded}
</span> percentage="0"
{expanded && ( />;
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">-</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={0} />
</div>
</div>
);
} }
const percent = Math.round((data.memory.active / data.memory.total) * 100); const percent = Math.round((data.memory.active / data.memory.total) * 100);
return ( return <Resource
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> icon={FaMemory}
<FaMemory className="text-theme-800 dark:text-theme-200 w-5 h-5" /> value={t("common.bytes", { value: data.memory.available, maximumFractionDigits: 1, binary: true })}
<div className="flex flex-col ml-3 text-left min-w-[85px]"> label={t("resources.free")}
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between"> expandedValue={t("common.bytes", { value: data.memory.total, maximumFractionDigits: 1, binary: true })}
<div className="pl-0.5 pr-1"> expandedLabel={t("resources.total")}
{t("common.bytes", { value: data.memory.available, maximumFractionDigits: 1, binary: true })} percentage={percent}
</div> expanded={expanded}
<div className="pr-1">{t("resources.free")}</div> />;
</span>
{expanded && (
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5 pr-1">
{t("common.bytes", {
value: data.memory.total,
maximumFractionDigits: 1,
binary: true,
})}
</div>
<div className="pr-1">{t("resources.total")}</div>
</span>
)}
<UsageBar percent={percent} />
</div>
</div>
);
} }

View File

@ -1,3 +1,6 @@
import Container from "../widget/container";
import Raw from "../widget/raw";
import Disk from "./disk"; import Disk from "./disk";
import Cpu from "./cpu"; import Cpu from "./cpu";
import Memory from "./memory"; import Memory from "./memory";
@ -6,8 +9,8 @@ import Uptime from "./uptime";
export default function Resources({ options }) { export default function Resources({ options }) {
const { expanded, units } = options; const { expanded, units } = options;
return ( return <Container options={options}>
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap"> <Raw>
<div className="flex flex-row self-center flex-wrap justify-between"> <div className="flex flex-row self-center flex-wrap justify-between">
{options.cpu && <Cpu expanded={expanded} />} {options.cpu && <Cpu expanded={expanded} />}
{options.memory && <Memory expanded={expanded} />} {options.memory && <Memory expanded={expanded} />}
@ -20,6 +23,6 @@ export default function Resources({ options }) {
{options.label && ( {options.label && (
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div> <div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
)} )}
</div> </Raw>
); </Container>;
} }

View File

@ -1,9 +1,9 @@
import useSWR from "swr"; import useSWR from "swr";
import { FaRegClock } from "react-icons/fa"; import { FaRegClock } from "react-icons/fa";
import { BiError } from "react-icons/bi";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar"; import Resource from "../widget/resource";
import Error from "../widget/error";
export default function Uptime() { export default function Uptime() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -13,54 +13,24 @@ export default function Uptime() {
}); });
if (error || data?.error) { if (error || data?.error) {
return ( return <Error />
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<BiError className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("widget.api_error")}</span>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Resource icon={FaRegClock} value="-" label={t("resources.uptime")} percentage="0" />;
<div className="flex-none flex flex-row items-center mr-3 py-1.5 animate-pulse">
<FaRegClock className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">-</div>
<div className="pr-1">{t("resources.temp")}</div>
</span>
</div>
</div>
);
} }
const mo = Math.floor(data.uptime / (3600 * 24 * 31)); const mo = Math.floor(data.uptime / (3600 * 24 * 31));
const d = Math.floor(data.uptime % (3600 * 24 * 31) / (3600 * 24)); const d = Math.floor(data.uptime % (3600 * 24 * 31) / (3600 * 24));
const h = Math.floor(data.uptime % (3600 * 24) / 3600); const h = Math.floor(data.uptime % (3600 * 24) / 3600);
const m = Math.floor(data.uptime % 3600 / 60); const m = Math.floor(data.uptime % 3600 / 60);
let uptime; let uptime;
if (mo > 0) uptime = `${mo}${t("resources.months")} ${d}${t("resources.days")}`; if (mo > 0) uptime = `${mo}${t("resources.months")} ${d}${t("resources.days")}`;
else if (d > 0) uptime = `${d}${t("resources.days")} ${h}${t("resources.hours")}`; else if (d > 0) uptime = `${d}${t("resources.days")} ${h}${t("resources.hours")}`;
else uptime = `${h}${t("resources.hours")} ${m}${t("resources.minutes")}`; else uptime = `${h}${t("resources.hours")} ${m}${t("resources.minutes")}`;
const percent = Math.round((new Date().getSeconds() / 60) * 100); const percent = Math.round((new Date().getSeconds() / 60) * 100).toString();
return ( return <Resource icon={FaRegClock} value={uptime} label={t("resources.uptime")} percentage={percent} />;
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<FaRegClock className="text-theme-800 dark:text-theme-200 w-5 h-5" />
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<span className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">
{uptime}
</div>
<div className="pr-1">{t("resources.uptime")}</div>
</span>
<UsageBar percent={percent} />
</div>
</div>
);
} }

View File

@ -1,10 +1,13 @@
import { useState, useEffect, Fragment } from "react"; import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { FiSearch } from "react-icons/fi"; import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si"; import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si";
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
import classNames from "classnames"; import classNames from "classnames";
import ContainerForm from "../widget/container_form";
import Raw from "../widget/raw";
export const searchProviders = { export const searchProviders = {
google: { google: {
name: "Google", name: "Google",
@ -76,14 +79,9 @@ export default function Search({ options }) {
setSelectedProvider(storedProvider); setSelectedProvider(storedProvider);
} }
}, [availableProviderIds]); }, [availableProviderIds]);
if (!availableProviderIds) {
return null;
}
function handleSubmit(event) { const submitCallback = useCallback(event => {
const q = encodeURIComponent(query); const q = encodeURIComponent(query);
const { url } = selectedProvider; const { url } = selectedProvider;
if (url) { if (url) {
window.open(`${url}${q}`, options.target || "_blank"); window.open(`${url}${q}`, options.target || "_blank");
@ -94,6 +92,10 @@ export default function Search({ options }) {
event.preventDefault(); event.preventDefault();
event.target.reset(); event.target.reset();
setQuery(""); setQuery("");
}, [options.target, options.url, query, selectedProvider]);
if (!availableProviderIds) {
return null;
} }
const onChangeProvider = (provider) => { const onChangeProvider = (provider) => {
@ -101,77 +103,79 @@ export default function Search({ options }) {
localStorage.setItem(localStorageKey, provider.name); localStorage.setItem(localStorageKey, provider.name);
} }
return ( return <ContainerForm options={options} callback={submitCallback} additionalClassNames="grow" >
<form className="flex-col relative h-8 my-4 min-w-fit grow first:ml-0 ml-4" onSubmit={handleSubmit}> <Raw>
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" /> <div className="flex-col relative h-8 my-4 min-w-fit">
<input <div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
type="text" <input
className=" type="text"
overflow-hidden w-full h-full rounded-md className="
text-xs text-theme-900 dark:text-white overflow-hidden w-full h-full rounded-md
placeholder-theme-900 dark:placeholder-white/80 text-xs text-theme-900 dark:text-white
bg-white/50 dark:bg-white/10 placeholder-theme-900 dark:placeholder-white/80
focus:ring-theme-500 dark:focus:ring-white/50 bg-white/50 dark:bg-white/10
focus:border-theme-500 dark:focus:border-white/50 focus:ring-theme-500 dark:focus:ring-white/50
border border-theme-300 dark:border-theme-200/50" focus:border-theme-500 dark:focus:border-white/50
placeholder={t("search.placeholder")} border border-theme-300 dark:border-theme-200/50"
onChange={(s) => setQuery(s.currentTarget.value)} placeholder={t("search.placeholder")}
required onChange={(s) => setQuery(s.currentTarget.value)}
autoCapitalize="off" required
autoCorrect="off" autoCapitalize="off"
autoComplete="off" autoCorrect="off"
// eslint-disable-next-line jsx-a11y/no-autofocus autoComplete="off"
autoFocus={options.focus} // eslint-disable-next-line jsx-a11y/no-autofocus
/> autoFocus={options.focus}
<Listbox as="div" value={selectedProvider} onChange={onChangeProvider} className="relative text-left" disabled={availableProviderIds?.length === 1}> />
<div> <Listbox as="div" value={selectedProvider} onChange={onChangeProvider} className="relative text-left" disabled={availableProviderIds?.length === 1}>
<Listbox.Button <div>
className=" <Listbox.Button
absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1 className="
text-white font-medium text-sm absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
bg-theme-600/40 dark:bg-white/10 text-white font-medium text-sm
focus:ring-theme-500 dark:focus:ring-white/50" bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<selectedProvider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</Listbox.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
> >
<selectedProvider.icon className="text-white w-3 h-3" /> <Listbox.Options
<span className="sr-only">{t("search.search")}</span> className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
</Listbox.Button> bg-theme-100 dark:bg-theme-600 shadow-lg
</div> ring-1 ring-black ring-opacity-5 focus:outline-none"
<Transition >
as={Fragment} <div className="flex flex-col">
enter="transition ease-out duration-100" {availableProviderIds.map((providerId) => {
enterFrom="transform opacity-0 scale-95" const p = searchProviders[providerId];
enterTo="transform opacity-100 scale-100" return (
leave="transition ease-in duration-75" <Listbox.Option key={providerId} value={p} as={Fragment}>
leaveFrom="transform opacity-100 scale-100" {({ active }) => (
leaveTo="transform opacity-0 scale-95" <li
> className={classNames(
<Listbox.Options "rounded-md cursor-pointer",
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100"
bg-theme-100 dark:bg-theme-600 shadow-lg )}
ring-1 ring-black ring-opacity-5 focus:outline-none" >
> <p.icon className="h-4 w-4 mx-4 my-2" />
<div className="flex flex-col"> </li>
{availableProviderIds.map((providerId) => { )}
const p = searchProviders[providerId]; </Listbox.Option>
return ( );
<Listbox.Option key={providerId} value={p} as={Fragment}> })}
{({ active }) => ( </div>
<li </Listbox.Options>
className={classNames( </Transition>
"rounded-md cursor-pointer", </Listbox>
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100" </div>
)} </Raw>
> </ContainerForm>;
<p.icon className="h-4 w-4 mx-4 my-2" />
</li>
)}
</Listbox.Option>
);
})}
</div>
</Listbox.Options>
</Transition>
</Listbox>
</form>
);
} }

View File

@ -3,6 +3,12 @@ import { MdSettingsEthernet } from "react-icons/md";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { SiUbiquiti } from "react-icons/si"; import { SiUbiquiti } from "react-icons/si";
import Error from "../widget/error";
import Container from "../widget/container";
import Raw from "../widget/raw";
import WidgetIcon from "../widget/widget_icon";
import PrimaryText from "../widget/primary_text";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Widget({ options }) { export default function Widget({ options }) {
@ -13,35 +19,16 @@ export default function Widget({ options }) {
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index }); const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
if (statsError) { if (statsError) {
return ( return <Error options={options} />
<div className="flex flex-col justify-center first:ml-0 ml-4">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
</div>
</div>
</div>
</div>
);
} }
const defaultSite = options.site ? statsData?.data.find(s => s.desc === options.site) : statsData?.data?.find(s => s.name === "default"); const defaultSite = options.site ? statsData?.data.find(s => s.desc === options.site) : statsData?.data?.find(s => s.name === "default");
if (!defaultSite) { if (!defaultSite) {
return ( return <Container options={options}>
<div className="flex flex-col justify-center first:ml-0 ml-4"> <PrimaryText>{t("unifi.wait")}</PrimaryText>
<div className="flex flex-row items-center justify-end"> <WidgetIcon icon={SiUbiquiti} />
<div className="flex flex-col items-center"> </Container>;
<SiUbiquiti className="w-5 h-5 text-theme-800 dark:text-theme-200" />
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("unifi.wait")}</span>
</div>
</div>
</div>
);
} }
const wan = defaultSite.health.find(h => h.subsystem === "wan"); const wan = defaultSite.health.find(h => h.subsystem === "wan");
@ -56,8 +43,9 @@ export default function Widget({ options }) {
const dataEmpty = !(wan.show || lan.show || wlan.show || uptime); const dataEmpty = !(wan.show || lan.show || wlan.show || uptime);
return ( return <Container options={options}>
<div className="flex-none flex flex-row items-center mr-3 py-1.5"> <Raw>
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row ml-3 mb-0.5"> <div className="flex flex-row ml-3 mb-0.5">
<SiUbiquiti className="text-theme-800 dark:text-theme-200 w-3 h-3 mr-1" /> <SiUbiquiti className="text-theme-800 dark:text-theme-200 w-3 h-3 mr-1" />
@ -141,6 +129,7 @@ export default function Widget({ options }) {
</div> </div>
</div>} </div>}
</div> </div>
</div> </div>
); </Raw>
</Container>
} }

View File

@ -1,10 +1,16 @@
import useSWR from "swr"; import useSWR from "swr";
import { useState } from "react"; import { useState } from "react";
import { BiError } from "react-icons/bi";
import { WiCloudDown } from "react-icons/wi"; import { WiCloudDown } from "react-icons/wi";
import { MdLocationDisabled, MdLocationSearching } from "react-icons/md"; import { MdLocationDisabled, MdLocationSearching } from "react-icons/md";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Error from "../widget/error";
import Container from "../widget/container";
import PrimaryText from "../widget/primary_text";
import SecondaryText from "../widget/secondary_text";
import WidgetIcon from "../widget/widget_icon";
import ContainerButton from "../widget/container_button";
import Icon from "./icon"; import Icon from "./icon";
function Widget({ options }) { function Widget({ options }) {
@ -15,59 +21,35 @@ function Widget({ options }) {
); );
if (error || data?.error) { if (error || data?.error) {
return ( return <Error options={options} />
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
</div>
);
} }
if (!data) { if (!data) {
return ( return <Container options={options}>
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2"> <PrimaryText>{t("weather.updating")}</PrimaryText>
<div className="flex flex-row items-center justify-end"> <SecondaryText>{t("weather.wait")}</SecondaryText>
<div className="flex flex-col items-center"> <WidgetIcon icon={WiCloudDown} size="l" />
<WiCloudDown className="w-8 h-8 text-theme-800 dark:text-theme-200" /> </Container>;
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.updating")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.wait")}</span>
</div>
</div>
</div>
);
} }
const unit = options.units === "metric" ? "celsius" : "fahrenheit"; const unit = options.units === "metric" ? "celsius" : "fahrenheit";
const weatherInfo = {
condition: data.current.condition.code,
timeOfDay: data.current.is_day ? "day" : "night",
};
return ( return <Container options={options}>
<div className="flex flex-col justify-center first:ml-0 ml-4 mr-2"> <PrimaryText>
<div className="flex flex-row items-center justify-end"> {options.label && `${options.label}, `}
<div className="flex flex-col items-center"> {t("common.number", {
<Icon condition={data.current.condition.code} timeOfDay={data.current.is_day ? "day" : "night"} /> value: options.units === "metric" ? data.current.temp_c : data.current.temp_f,
</div> style: "unit",
<div className="flex flex-col ml-3 text-left"> unit,
<span className="text-theme-800 dark:text-theme-200 text-sm"> })}
{options.label && `${options.label}, `} </PrimaryText>
{t("common.number", { <SecondaryText>{data.current.condition.text}</SecondaryText>
value: options.units === "metric" ? data.current.temp_c : data.current.temp_f, <WidgetIcon icon={Icon} size="xl" weatherInfo={weatherInfo} />
style: "unit", </Container>;
unit,
})}
</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{data.current.condition.text}</span>
</div>
</div>
</div>
);
} }
export default function WeatherApi({ options }) { export default function WeatherApi({ options }) {
@ -99,30 +81,12 @@ export default function WeatherApi({ options }) {
} }
}; };
// if (!requesting && !location) requestLocation();
if (!location) { if (!location) {
return ( return <ContainerButton options={options} callback={requestLocation} >
<button <PrimaryText>{t("weather.current")}</PrimaryText>
type="button" <SecondaryText>{t("weather.allow")}</SecondaryText>
onClick={() => requestLocation()} <WidgetIcon icon={requesting ? MdLocationSearching : MdLocationDisabled} size="m" pulse />
className="flex flex-col justify-center first:ml-0 ml-4 mr-2" </ContainerButton>;
>
<div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">
{requesting ? (
<MdLocationSearching className="w-6 h-6 text-theme-800 dark:text-theme-200 animate-pulse" />
) : (
<MdLocationDisabled className="w-6 h-6 text-theme-800 dark:text-theme-200" />
)}
</div>
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("weather.current")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("weather.allow")}</span>
</div>
</div>
</button>
);
} }
return <Widget options={{ ...location, ...options }} />; return <Widget options={{ ...location, ...options }} />;

View File

@ -17,13 +17,13 @@ const widgetMappings = {
kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")), kubernetes: dynamic(() => import("components/widgets/kubernetes/kubernetes")),
}; };
export default function Widget({ widget }) { export default function Widget({ widget, style }) {
const InfoWidget = widgetMappings[widget.type]; const InfoWidget = widgetMappings[widget.type];
if (InfoWidget) { if (InfoWidget) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<InfoWidget options={widget.options} /> <InfoWidget options={{ ...widget.options, style }} />
</ErrorBoundary> </ErrorBoundary>
); );
} }

View File

@ -0,0 +1,54 @@
import classNames from "classnames";
import WidgetIcon from "./widget_icon";
import PrimaryText from "./primary_text";
import SecondaryText from "./secondary_text";
import Raw from "./raw";
export function getAllClasses(options, additionalClassNames = '') {
if (options?.style?.header === "boxedWidgets") {
return classNames(
"flex flex-col justify-center first:ml-0 ml-2 mr-2",
"mt-2 m:mb-0 rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 dark:bg-white/5 p-2 pl-3 pr-3",
additionalClassNames
);
}
let widgetAlignedClasses = "flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap";
if (options?.style?.isRightAligned) {
widgetAlignedClasses = "flex flex-col justify-center first:ml-auto ml-2 mr-2 ";
}
return classNames(
widgetAlignedClasses,
additionalClassNames
);
}
export function getInnerBlock(children) {
// children won't be an array if it's Raw component
return Array.isArray(children) && <div className="flex flex-row items-center justify-end">
<div className="flex flex-col items-center">{children.find(child => child.type === WidgetIcon)}</div>
<div className="flex flex-col ml-3 text-left">
{children.find(child => child.type === PrimaryText)}
{children.find(child => child.type === SecondaryText)}
</div>
</div>;
}
export function getBottomBlock(children) {
if (children.type !== Raw) {
return children.find(child => child.type === Raw) || [];
}
return [children];
}
export default function Container({ children = [], options, additionalClassNames = '' }) {
return (
<div className={getAllClasses(options, additionalClassNames)}>
{getInnerBlock(children)}
{getBottomBlock(children)}
</div>
);
}

View File

@ -0,0 +1,10 @@
import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerButton ({ children = [], options, additionalClassNames = '', callback }) {
return (
<button type="button" onClick={callback} className={getAllClasses(options, additionalClassNames)}>
{getInnerBlock(children)}
{getBottomBlock(children)}
</button>
);
}

View File

@ -0,0 +1,10 @@
import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerForm ({ children = [], options, additionalClassNames = '', callback }) {
return (
<form type="button" onSubmit={callback} className={getAllClasses(options, additionalClassNames)}>
{getInnerBlock(children)}
{getBottomBlock(children)}
</form>
);
}

View File

@ -0,0 +1,10 @@
import { getAllClasses, getInnerBlock, getBottomBlock } from "./container";
export default function ContainerLink ({ children = [], options, additionalClassNames = '', target }) {
return (
<a href={options.url} target={target} className={getAllClasses(options, additionalClassNames)}>
{getInnerBlock(children)}
{getBottomBlock(children)}
</a>
);
}

View File

@ -0,0 +1,15 @@
import { useTranslation } from "react-i18next";
import { BiError } from "react-icons/bi";
import Container from "./container";
import PrimaryText from "./primary_text";
import WidgetIcon from "./widget_icon";
export default function Error({ options }) {
const { t } = useTranslation();
return <Container options={options}>
<PrimaryText>{t("widget.api_error")}</PrimaryText>
<WidgetIcon icon={BiError} size="l" />
</Container>;
}

View File

@ -0,0 +1,5 @@
export default function PrimaryText({ children }) {
return (
<span className="text-theme-800 dark:text-theme-200 text-sm">{children}</span>
);
}

View File

@ -0,0 +1,7 @@
export default function Raw({ children }) {
if (children.type === Raw) {
return [children];
}
return children;
}

View File

@ -0,0 +1,22 @@
import UsageBar from "../resources/usage-bar";
export default function Resource({ children, icon, value, label, expandedValue = "", expandedLabel = "", percentage, expanded = false }) {
const Icon = icon;
return <div className="flex-none flex flex-row items-center mr-3 py-1.5">
<Icon className="text-theme-800 dark:text-theme-200 w-5 h-5"/>
<div className="flex flex-col ml-3 text-left min-w-[85px]">
<div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{value}</div>
<div className="pr-1">{label}</div>
</div>
{ expanded && <div className="text-theme-800 dark:text-theme-200 text-xs flex flex-row justify-between">
<div className="pl-0.5">{expandedValue}</div>
<div className="pr-1">{expandedLabel}</div>
</div>
}
{ percentage && <UsageBar percent={percentage} /> }
{ children }
</div>
</div>;
}

View File

@ -0,0 +1,17 @@
import ContainerLink from "./container_link";
import Resource from "./resource";
import Raw from "./raw";
import WidgetLabel from "./widget_label";
export default function Resources({ options, children, target }) {
const widgetParts = [].concat(...children);
return <ContainerLink options={options} target={target}>
<Raw>
<div className="flex flex-row self-center flex-wrap justify-between">
{ widgetParts.filter(child => child && child.type === Resource) }
</div>
{ widgetParts.filter(child => child && child.type === WidgetLabel) }
</Raw>
</ContainerLink>;
}

View File

@ -0,0 +1,5 @@
export default function SecondaryText({ children }) {
return (
<span className="text-theme-800 dark:text-theme-200 text-xs">{children}</span>
);
}

View File

@ -0,0 +1,18 @@
export default function WidgetIcon({ icon, size = "s", pulse = false, weatherInfo = {} }) {
const Icon = icon;
const { condition, timeOfDay } = weatherInfo;
let additionalClasses = "text-theme-800 dark:text-theme-200 ";
switch (size) {
case "m": additionalClasses += "w-6 h-6 "; break;
case "l": additionalClasses += "w-8 h-8 "; break;
case "xl": additionalClasses += "w-10 h-10 "; break;
default: additionalClasses += "w-5 h-5 ";
}
if (pulse) {
additionalClasses += "animate-pulse ";
}
return <Icon className={additionalClasses} condition={condition} timeOfDay={timeOfDay} />;
}

View File

@ -0,0 +1,3 @@
export default function WidgetLabel({ label = "" }) {
return <div className="pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{label}</div>
}

View File

@ -1,12 +1,22 @@
import { performance } from "perf_hooks"; import { performance } from "perf_hooks";
import { getServiceItem } from "utils/config/service-helpers";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
import { httpProxy } from "utils/proxy/http"; import { httpProxy } from "utils/proxy/http";
const logger = createLogger("ping"); const logger = createLogger("ping");
export default async function handler(req, res) { export default async function handler(req, res) {
const { ping: pingURL } = req.query; const { group, service } = req.query;
const serviceItem = await getServiceItem(group, service);
if (!serviceItem) {
logger.debug(`No service item found for group ${group} named ${service}`);
return res.status(400).send({
error: "Unable to find service, see log for details.",
});
}
const { ping: pingURL } = serviceItem;
if (!pingURL) { if (!pingURL) {
logger.debug("No ping URL specified"); logger.debug("No ping URL specified");

View File

@ -46,7 +46,7 @@ function parseLonghornData(data) {
export default async function handler(req, res) { export default async function handler(req, res) {
const settings = getSettings(); const settings = getSettings();
const longhornSettings = settings?.providers?.longhorn; const longhornSettings = settings?.providers?.longhorn || {};
const {url, username, password} = longhornSettings; const {url, username, password} = longhornSettings;
if (!url) { if (!url) {

View File

@ -160,6 +160,7 @@ const headerStyles = {
"m-4 mb-0 sm:m-8 sm:mb-0 rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 dark:bg-white/5 p-3", "m-4 mb-0 sm:m-8 sm:mb-0 rounded-md shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-100/20 dark:bg-white/5 p-3",
underlined: "m-4 mb-0 sm:m-8 sm:mb-1 border-b-2 pb-4 border-theme-800 dark:border-theme-200/50", underlined: "m-4 mb-0 sm:m-8 sm:mb-1 border-b-2 pb-4 border-theme-800 dark:border-theme-200/50",
clean: "m-4 mb-0 sm:m-8 sm:mb-0", clean: "m-4 mb-0 sm:m-8 sm:mb-0",
boxedWidgets: "m-4 mb-0 sm:m-8 sm:mb-0 sm:mt-1",
}; };
function Home({ initialSettings }) { function Home({ initialSettings }) {
@ -208,6 +209,7 @@ function Home({ initialSettings }) {
searchProvider = searchProviders[searchWidget.options?.provider]; searchProvider = searchProviders[searchWidget.options?.provider];
} }
} }
const headerStyle = initialSettings?.headerStyle || "underlined";
useEffect(() => { useEffect(() => {
function handleKeyDown(e) { function handleKeyDown(e) {
@ -252,11 +254,11 @@ function Home({ initialSettings }) {
/> />
<meta name="theme-color" content={themes[initialSettings.color || "slate"][initialSettings.theme || "dark"]} /> <meta name="theme-color" content={themes[initialSettings.color || "slate"][initialSettings.theme || "dark"]} />
</Head> </Head>
<div className="relative container m-auto flex flex-col justify-between z-10 h-full"> <div className="relative container m-auto flex flex-col justify-start z-10 h-full">
<div <div
className={classNames( className={classNames(
"flex flex-row flex-wrap justify-between", "flex flex-row flex-wrap justify-between",
headerStyles[initialSettings.headerStyle || "underlined"] headerStyles[headerStyle]
)} )}
> >
<QuickLaunch <QuickLaunch
@ -272,14 +274,17 @@ function Home({ initialSettings }) {
{widgets {widgets
.filter((widget) => !rightAlignedWidgets.includes(widget.type)) .filter((widget) => !rightAlignedWidgets.includes(widget.type))
.map((widget, i) => ( .map((widget, i) => (
<Widget key={i} widget={widget} /> <Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: false}} />
))} ))}
<div className="m-auto sm:ml-2 flex flex-wrap grow sm:basis-auto justify-between md:justify-end"> <div className={classNames(
"m-auto flex flex-wrap grow sm:basis-auto justify-between md:justify-end",
headerStyle === "boxedWidgets" ? "sm:ml-4" : "sm:ml-2"
)}>
{widgets {widgets
.filter((widget) => rightAlignedWidgets.includes(widget.type)) .filter((widget) => rightAlignedWidgets.includes(widget.type))
.map((widget, i) => ( .map((widget, i) => (
<Widget key={i} widget={widget} /> <Widget key={i} widget={widget} style={{ header: headerStyle, isRightAligned: true}} />
))} ))}
</div> </div>
</> </>
@ -289,7 +294,7 @@ function Home({ initialSettings }) {
{services?.length > 0 && ( {services?.length > 0 && (
<div className="flex flex-wrap p-4 sm:p-8 sm:pt-4 items-start pb-2"> <div className="flex flex-wrap p-4 sm:p-8 sm:pt-4 items-start pb-2">
{services.map((group) => ( {services.map((group) => (
<ServicesGroup key={group.name} services={group} layout={initialSettings.layout?.[group.name]} fiveColumns={settings.fiveColumns} /> <ServicesGroup key={group.name} group={group.name} services={group} layout={initialSettings.layout?.[group.name]} fiveColumns={settings.fiveColumns} />
))} ))}
</div> </div>
)} )}
@ -302,14 +307,16 @@ function Home({ initialSettings }) {
</div> </div>
)} )}
<div className="flex p-8 pb-0 w-full justify-end"> <div className="flex flex-col mt-auto p-8 w-full">
{!initialSettings?.color && <ColorToggle />} <div className="flex w-full justify-end">
<Revalidate /> {!initialSettings?.color && <ColorToggle />}
{!initialSettings?.theme && <ThemeToggle />} <Revalidate />
</div> {!initialSettings?.theme && <ThemeToggle />}
</div>
<div className="flex p-8 pt-4 w-full justify-end"> <div className="flex mt-4 w-full justify-end">
{!initialSettings?.hideVersion && <Version />} {!initialSettings?.hideVersion && <Version />}
</div>
</div> </div>
</div> </div>
</> </>
@ -357,7 +364,7 @@ export default function Wrapper({ initialSettings, fallback }) {
style={wrappedStyle} style={wrappedStyle}
> >
<div <div
id="inner_wrapper" id="inner_wrapper"
className={classNames( className={classNames(
'fixed overflow-auto w-full h-full', 'fixed overflow-auto w-full h-full',
backgroundBlur && `backdrop-blur${initialSettings.background.blur.length ? '-' : ""}${initialSettings.background.blur}`, backgroundBlur && `backdrop-blur${initialSettings.background.blur.length ? '-' : ""}${initialSettings.background.blur}`,

View File

@ -63,10 +63,10 @@ export async function servicesFromDocker() {
const serviceServers = await Promise.all( const serviceServers = await Promise.all(
Object.keys(servers).map(async (serverName) => { Object.keys(servers).map(async (serverName) => {
try { try {
const isSwarm = !!servers[serverName].swarm;
const docker = new Docker(getDockerArguments(serverName).conn); const docker = new Docker(getDockerArguments(serverName).conn);
const containers = await docker.listContainers({ const listProperties = { all: true };
all: true, const containers = await ((isSwarm) ? docker.listServices(listProperties) : docker.listContainers(listProperties));
});
// bad docker connections can result in a <Buffer ...> object? // bad docker connections can result in a <Buffer ...> object?
// in any case, this ensures the result is the expected array // in any case, this ensures the result is the expected array
@ -76,17 +76,19 @@ export async function servicesFromDocker() {
const discovered = containers.map((container) => { const discovered = containers.map((container) => {
let constructedService = null; let constructedService = null;
const containerLabels = isSwarm ? shvl.get(container, 'Spec.Labels') : container.Labels;
const containerName = isSwarm ? shvl.get(container, 'Spec.Name') : container.Names[0];
Object.keys(container.Labels).forEach((label) => { Object.keys(containerLabels).forEach((label) => {
if (label.startsWith("homepage.")) { if (label.startsWith("homepage.")) {
if (!constructedService) { if (!constructedService) {
constructedService = { constructedService = {
container: container.Names[0].replace(/^\//, ""), container: containerName.replace(/^\//, ""),
server: serverName, server: serverName,
type: 'service' type: 'service'
}; };
} }
shvl.set(constructedService, label.replace("homepage.", ""), substituteEnvironmentVars(container.Labels[label])); shvl.set(constructedService, label.replace("homepage.", ""), substituteEnvironmentVars(containerLabels[label]));
} }
}); });
@ -156,11 +158,20 @@ export async function servicesFromKubernetes() {
return null; return null;
}); });
const traefikIngressList = await crd.listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes") const traefikIngressList = await crd.listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes")
.then((response) => response.body) .then((response) => response.body)
.catch((error) => { .catch(async (error) => {
logger.error("Error getting traefik ingresses: %d %s %s", error.statusCode, error.body, error.response); logger.error("Error getting traefik ingresses from traefik.io: %d %s %s", error.statusCode, error.body, error.response);
return null;
// Fallback to the old traefik CRD group
const fallbackIngressList = await crd.listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch((fallbackError) => {
logger.error("Error getting traefik ingresses from traefik.containo.us: %d %s %s", fallbackError.statusCode, fallbackError.body, fallbackError.response);
return null;
});
return fallbackIngressList;
}); });
if (traefikIngressList && traefikIngressList.items.length > 0) { if (traefikIngressList && traefikIngressList.items.length > 0) {
@ -168,7 +179,7 @@ export async function servicesFromKubernetes() {
.filter((ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`]) .filter((ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`])
ingressList.items.push(...traefikServices); ingressList.items.push(...traefikServices);
} }
if (!ingressList) { if (!ingressList) {
return []; return [];
} }
@ -276,7 +287,8 @@ export function cleanServiceGroups(groups) {
wan, // opnsense widget, pfsense widget wan, // opnsense widget, pfsense widget
enableBlocks, // emby/jellyfin enableBlocks, // emby/jellyfin
enableNowPlaying, enableNowPlaying,
volume, // diskstation widget volume, // diskstation widget,
enableQueue, // sonarr/radarr
} = cleanedService.widget; } = cleanedService.widget;
const fieldsList = typeof fields === 'string' ? JSON.parse(fields) : fields; const fieldsList = typeof fields === 'string' ? JSON.parse(fields) : fields;
@ -312,6 +324,9 @@ export function cleanServiceGroups(groups) {
if (enableBlocks !== undefined) cleanedService.widget.enableBlocks = JSON.parse(enableBlocks); if (enableBlocks !== undefined) cleanedService.widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) cleanedService.widget.enableNowPlaying = JSON.parse(enableNowPlaying); if (enableNowPlaying !== undefined) cleanedService.widget.enableNowPlaying = JSON.parse(enableNowPlaying);
} }
if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue);
}
if (["diskstation", "qnap"].includes(type)) { if (["diskstation", "qnap"].includes(type)) {
if (volume) cleanedService.widget.volume = volume; if (volume) cleanedService.widget.volume = volume;
} }
@ -322,16 +337,13 @@ export function cleanServiceGroups(groups) {
})); }));
} }
export default async function getServiceWidget(group, service) { export async function getServiceItem(group, service) {
const configuredServices = await servicesFromConfig(); const configuredServices = await servicesFromConfig();
const serviceGroup = configuredServices.find((g) => g.name === group); const serviceGroup = configuredServices.find((g) => g.name === group);
if (serviceGroup) { if (serviceGroup) {
const serviceEntry = serviceGroup.services.find((s) => s.name === service); const serviceEntry = serviceGroup.services.find((s) => s.name === service);
if (serviceEntry) { if (serviceEntry) return serviceEntry;
const { widget } = serviceEntry;
return widget;
}
} }
const discoveredServices = await servicesFromDocker(); const discoveredServices = await servicesFromDocker();
@ -339,20 +351,24 @@ export default async function getServiceWidget(group, service) {
const dockerServiceGroup = discoveredServices.find((g) => g.name === group); const dockerServiceGroup = discoveredServices.find((g) => g.name === group);
if (dockerServiceGroup) { if (dockerServiceGroup) {
const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service); const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);
if (dockerServiceEntry) { if (dockerServiceEntry) return dockerServiceEntry;
const { widget } = dockerServiceEntry;
return widget;
}
} }
const kubernetesServices = await servicesFromKubernetes(); const kubernetesServices = await servicesFromKubernetes();
const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group); const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group);
if (kubernetesServiceGroup) { if (kubernetesServiceGroup) {
const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service); const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);
if (kubernetesServiceEntry) { if (kubernetesServiceEntry) return kubernetesServiceEntry;
const { widget } = kubernetesServiceEntry; }
return widget;
} return false;
}
export default async function getServiceWidget(group, service) {
const serviceItem = await getServiceItem(group, service);
if (serviceItem) {
const { widget } = serviceItem;
return widget;
} }
return false; return false;

View File

@ -55,6 +55,12 @@ export default async function credentialedProxyHandler(req, res, map) {
} else { } else {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`; headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
} }
} else if (widget.type === "paperlessngx") {
if (widget.key) {
headers.Authorization = `Token ${widget.key}`;
} else {
headers.Authorization = `Basic ${Buffer.from(`${widget.username}:${widget.password}`).toString("base64")}`;
}
} else { } else {
headers["X-API-Key"] = `${widget.key}`; headers["X-API-Key"] = `${widget.key}`;
} }

View File

@ -1,5 +1,7 @@
/* eslint-disable prefer-promise-reject-errors */ /* eslint-disable prefer-promise-reject-errors */
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { createUnzip } from "node:zlib";
import { http, https } from "follow-redirects"; import { http, https } from "follow-redirects";
import { addCookieToJar, setCookieHeader } from "./cookie-jar"; import { addCookieToJar, setCookieHeader } from "./cookie-jar";
@ -28,12 +30,24 @@ function handleRequest(requestor, url, params) {
const request = requestor.request(url, params, (response) => { const request = requestor.request(url, params, (response) => {
const data = []; const data = [];
const contentEncoding = response.headers['content-encoding']?.trim().toLowerCase();
response.on("data", (chunk) => { let responseContent = response;
if (contentEncoding === 'gzip' || contentEncoding === 'deflate') {
responseContent = createUnzip();
// zlib errors
responseContent.on("error", (e) => {
logger.error(e);
responseContent = response; // fallback
});
response.pipe(responseContent);
}
responseContent.on("data", (chunk) => {
data.push(chunk); data.push(chunk);
}); });
response.on("end", () => { responseContent.on("end", () => {
addCookieToJar(url, response.headers); addCookieToJar(url, response.headers);
resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]); resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]);
}); });

View File

@ -7,7 +7,11 @@ export default function useWidgetAPI(widget, ...options) {
if (options && options[1]?.refreshInterval) { if (options && options[1]?.refreshInterval) {
config.refreshInterval = options[1].refreshInterval; config.refreshInterval = options[1].refreshInterval;
} }
const { data, error, mutate } = useSWR(formatProxyUrl(widget, ...options), config); let url = formatProxyUrl(widget, ...options)
if (options[0] === "") {
url = null
}
const { data, error, mutate } = useSWR(url, config);
// make the data error the top-level error // make the data error the top-level error
return { data, error: data?.error ?? error, mutate } return { data, error: data?.error ?? error, mutate }
} }

View File

@ -31,6 +31,7 @@ const components = {
healthchecks: dynamic(() => import("./healthchecks/component")), healthchecks: dynamic(() => import("./healthchecks/component")),
immich: dynamic(() => import("./immich/component")), immich: dynamic(() => import("./immich/component")),
jackett: dynamic(() => import("./jackett/component")), jackett: dynamic(() => import("./jackett/component")),
jdownloader: dynamic(() => import("./jdownloader/component")),
jellyfin: dynamic(() => import("./emby/component")), jellyfin: dynamic(() => import("./emby/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")), jellyseerr: dynamic(() => import("./jellyseerr/component")),
komga: dynamic(() => import("./komga/component")), komga: dynamic(() => import("./komga/component")),
@ -93,4 +94,4 @@ const components = {
xteve: dynamic(() => import("./xteve/component")), xteve: dynamic(() => import("./xteve/component")),
}; };
export default components; export default components;

View File

@ -63,7 +63,7 @@ async function apiCall(widget, endpoint, service) {
} }
if (status !== 200) { if (status !== 200) {
logger.error("Error getting data from Homebridge: %s status %d. Data: %s", url, status, data); logger.error("Error getting data from Homebridge: %s status %d. Data: %s", url, status, JSON.stringify(data));
return { status, contentType, data: null, responseHeaders }; return { status, contentType, data: null, responseHeaders };
} }

View File

@ -0,0 +1,39 @@
import { useTranslation } from "next-i18next";
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: jdownloaderData, error: jdownloaderAPIError } = useWidgetAPI(widget, "unified", {
refreshInterval: 30000,
});
if (jdownloaderAPIError) {
return <Container service={service} error={jdownloaderAPIError} />;
}
if (!jdownloaderData) {
return (
<Container service={service}>
<Block label="jdownloader.downloadCount" />
<Block label="jdownloader.downloadTotalBytes" />
<Block label="jdownloader.downloadBytesRemaining" />
<Block label="jdownloader.downloadSpeed" />
</Container>
);
}
return (
<Container service={service}>
<Block label="jdownloader.downloadCount" value={t("common.number", { value: jdownloaderData.downloadCount })} />
<Block label="jdownloader.downloadTotalBytes" value={t("common.bytes", { value: jdownloaderData.totalBytes })} />
<Block label="jdownloader.downloadBytesRemaining" value={t("common.bytes", { value: jdownloaderData.bytesRemaining })} />
<Block label="jdownloader.downloadSpeed" value={t("common.byterate", { value: jdownloaderData.totalSpeed })} />
</Container>
);
}

View File

@ -0,0 +1,196 @@
/* eslint-disable no-underscore-dangle */
import crypto from 'crypto';
import querystring from 'querystring';
import { sha256, uniqueRid, validateRid, createEncryptionToken, decrypt, encrypt } from "./tools"
import getServiceWidget from "utils/config/service-helpers";
import { httpProxy } from "utils/proxy/http";
import createLogger from "utils/logger";
const proxyName = "jdownloaderProxyHandler";
const logger = createLogger(proxyName);
async function getWidget(req) {
const { group, service } = req.query;
if (!group || !service) {
logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
return null;
}
const widget = await getServiceWidget(group, service);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
return null;
}
return widget;
}
async function login(loginSecret, deviceSecret, params) {
const rid = uniqueRid();
const path = `/my/connect?${querystring.stringify({ ...params, rid })}`;
const signature = crypto
.createHmac('sha256', loginSecret)
.update(path)
.digest('hex');
const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}`
const [status, contentType, data] = await httpProxy(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (status !== 200) {
logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString());
return [status, data];
}
try {
const decryptedData = JSON.parse(decrypt(data.toString(), loginSecret))
const sessionToken = decryptedData.sessiontoken;
validateRid(decryptedData, rid);
const serverEncryptionToken = createEncryptionToken(loginSecret, sessionToken);
const deviceEncryptionToken = createEncryptionToken(deviceSecret, sessionToken);
return [status, decryptedData, contentType, serverEncryptionToken, deviceEncryptionToken, sessionToken];
} catch (e) {
logger.error("Error decoding jdownloader API data. Data: %s", data.toString());
return [status, null];
}
}
async function getDevice(serverEncryptionToken, deviceName, params) {
const rid = uniqueRid();
const path = `/my/listdevices?${querystring.stringify({ ...params, rid })}`;
const signature = crypto
.createHmac('sha256', serverEncryptionToken)
.update(path)
.digest('hex');
const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}`
const [status, , data] = await httpProxy(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (status !== 200) {
logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString());
return [status, data];
}
try {
const decryptedData = JSON.parse(decrypt(data.toString(), serverEncryptionToken))
const filteredDevice = decryptedData.list.filter(device => device.name === deviceName);
return [status, filteredDevice[0].id];
} catch (e) {
logger.error("Error decoding jdownloader API data. Data: %s", data.toString());
return [status, null];
}
}
function createBody(rid, query, params) {
const baseBody = {
apiVer: 1,
rid,
url: query
};
return params ? { ...baseBody, params: [JSON.stringify(params)] } : baseBody;
}
async function queryPackages(deviceEncryptionToken, deviceId, sessionToken, params) {
const rid = uniqueRid();
const body = encrypt(JSON.stringify(createBody(rid, '/downloadsV2/queryPackages', params)), deviceEncryptionToken);
const url = `${new URL(`https://api.jdownloader.org/t_${encodeURI(sessionToken)}_${encodeURI(deviceId)}/downloadsV2/queryPackages`)}`
const [status, , data] = await httpProxy(url, {
method: 'POST',
body,
});
if (status !== 200) {
logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString());
return [status, data];
}
try {
const decryptedData = JSON.parse(decrypt(data.toString(), deviceEncryptionToken))
return decryptedData.data;
} catch (e) {
logger.error("Error decoding JDRss jdownloader data. Data: %s", data.toString());
return [status, null];
}
}
export default async function jdownloaderProxyHandler(req, res) {
const widget = await getWidget(req);
if (!widget) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
logger.debug("Getting data from JDRss API");
const { username } = widget
const { password } = widget
const appKey = "homepage"
const loginSecret = sha256(`${username}${password}server`)
const deviceSecret = sha256(`${username}${password}device`)
const email = username;
const loginData = await login(loginSecret, deviceSecret, {
appKey,
email
})
const deviceData = await getDevice(loginData[3], widget.client, {
sessiontoken: loginData[5]
})
const packageStatus = await queryPackages(loginData[4], deviceData[1], loginData[5], {
"bytesLoaded": false,
"bytesTotal": true,
"comment": false,
"enabled": true,
"eta": false,
"priority": false,
"finished": true,
"running": true,
"speed": true,
"status": true,
"childCount": false,
"hosts": false,
"saveTo": false,
"maxResults": -1,
"startAt": 0,
}
)
let bytesRemaining = 0;
let totalBytes = 0;
let totalSpeed = 0;
packageStatus.forEach(file => {
totalBytes += file.bytesTotal;
if (file.finished !== true) {
bytesRemaining += file.bytesTotal;
if (file.speed) {
totalSpeed += file.speed;
}
}
});
const data = {
downloadCount: packageStatus.length,
bytesRemaining,
totalBytes,
totalSpeed
};
return res.send(data);
}

View File

@ -0,0 +1,55 @@
import crypto from 'crypto';
export function sha256(data) {
return crypto
.createHash('sha256')
.update(data)
.digest();
}
export function uniqueRid() {
return Math.floor(Math.random() * 10e12);
}
export function validateRid(decryptedData, rid) {
if (decryptedData.rid !== rid) {
throw new Error('RequestID mismatch');
}
return decryptedData;
}
export function decrypt(data, ivKey) {
const iv = ivKey.slice(0, ivKey.length / 2);
const key = ivKey.slice(ivKey.length / 2, ivKey.length);
const cipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
return Buffer.concat([
cipher.update(data, 'base64'),
cipher.final()
]).toString();
}
export function createEncryptionToken(oldTokenBuff, updateToken) {
const updateTokenBuff = Buffer.from(updateToken, 'hex');
const mergedBuffer = Buffer.concat([oldTokenBuff, updateTokenBuff], oldTokenBuff.length + updateTokenBuff.length);
return sha256(mergedBuffer);
}
export function encrypt(data, ivKey) {
if (typeof data !== 'string') {
throw new Error('data no es un string');
}
if (!(ivKey instanceof Buffer)) {
throw new Error('ivKey no es un buffer');
}
if (ivKey.length !== 32) {
throw new Error('ivKey tiene que tener tamaño 32');
}
const stringIVKey = ivKey.toString('hex');
const stringIV = stringIVKey.substring(0, stringIVKey.length / 2);
const stringKey = stringIVKey.substring(stringIVKey.length / 2, stringIVKey.length);
const iv = Buffer.from(stringIV, 'hex');
const key = Buffer.from(stringKey, 'hex');
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
return cipher.update(data, 'utf8', 'base64') + cipher.final('base64');
}

View File

@ -0,0 +1,15 @@
import jdownloaderProxyHandler from "./proxy";
const widget = {
api: "https://api.jdownloader.org/{endpoint}/&signature={signature}",
proxyHandler: jdownloaderProxyHandler,
mappings: {
unified: {
endpoint: "/",
signature: "",
},
},
};
export default widget;

View File

@ -16,7 +16,7 @@ export default function Component({ service }) {
`/api/kubernetes/stats/${widget.namespace}/${widget.app}?${podSelectorString}`); `/api/kubernetes/stats/${widget.namespace}/${widget.app}?${podSelectorString}`);
if (statsError || statusError) { if (statsError || statusError) {
return <Container service={service} error={t("widget.api_error")} />; return <Container service={service} error={statsError ?? statusError} />;
} }
if (statusData && statusData.status !== "running") { if (statusData && statusData.status !== "running") {

View File

@ -9,21 +9,21 @@ export default function Component({ service }) {
const { widget } = service; const { widget } = service;
const { data: albumsData, error: albumsError } = useWidgetAPI(widget, "album"); const { data: artistsData, error: artistsError } = useWidgetAPI(widget, "artist");
const { data: wantedData, error: wantedError } = useWidgetAPI(widget, "wanted/missing"); const { data: wantedData, error: wantedError } = useWidgetAPI(widget, "wanted/missing");
const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue/status"); const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue/status");
if (albumsError || wantedError || queueError) { if (artistsError || wantedError || queueError) {
const finalError = albumsError ?? wantedError ?? queueError; const finalError = artistsError ?? wantedError ?? queueError;
return <Container service={service} error={finalError} />; return <Container service={service} error={finalError} />;
} }
if (!albumsData || !wantedData || !queueData) { if (!artistsData || !wantedData || !queueData) {
return ( return (
<Container service={service}> <Container service={service}>
<Block label="lidarr.wanted" /> <Block label="lidarr.wanted" />
<Block label="lidarr.queued" /> <Block label="lidarr.queued" />
<Block label="lidarr.albums" /> <Block label="lidarr.artists" />
</Container> </Container>
); );
} }
@ -32,7 +32,7 @@ export default function Component({ service }) {
<Container service={service}> <Container service={service}>
<Block label="lidarr.wanted" value={t("common.number", { value: wantedData.totalRecords })} /> <Block label="lidarr.wanted" value={t("common.number", { value: wantedData.totalRecords })} />
<Block label="lidarr.queued" value={t("common.number", { value: queueData.totalCount })} /> <Block label="lidarr.queued" value={t("common.number", { value: queueData.totalCount })} />
<Block label="lidarr.albums" value={t("common.number", { value: albumsData.have })} /> <Block label="lidarr.artists" value={t("common.number", { value: artistsData.length })} />
</Container> </Container>
); );
} }

View File

@ -1,16 +1,12 @@
import genericProxyHandler from "utils/proxy/handlers/generic"; import genericProxyHandler from "utils/proxy/handlers/generic";
import { jsonArrayFilter } from "utils/proxy/api-helpers";
const widget = { const widget = {
api: "{url}/api/v1/{endpoint}?apikey={key}", api: "{url}/api/v1/{endpoint}?apikey={key}",
proxyHandler: genericProxyHandler, proxyHandler: genericProxyHandler,
mappings: { mappings: {
album: { artist: {
endpoint: "album", endpoint: "artist",
map: (data) => ({
have: jsonArrayFilter(data, (item) => item?.statistics?.percentOfTracks === 100).length,
}),
}, },
"wanted/missing": { "wanted/missing": {
endpoint: "wanted/missing", endpoint: "wanted/missing",

View File

@ -1,8 +1,8 @@
import genericProxyHandler from "utils/proxy/handlers/generic"; import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
const widget = { const widget = {
api: "{url}/api/{endpoint}", api: "{url}/api/{endpoint}",
proxyHandler: genericProxyHandler, proxyHandler: credentialedProxyHandler,
mappings: { mappings: {
"statistics": { "statistics": {

View File

@ -1,12 +1,8 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container"; import Container from "components/services/widget/container";
import Block from "components/services/widget/block"; import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) { export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { data: containersData, error: containersError } = useWidgetAPI(widget, "docker/containers/json", { const { data: containersData, error: containersError } = useWidgetAPI(widget, "docker/containers/json", {
@ -27,8 +23,9 @@ export default function Component({ service }) {
); );
} }
if (containersData.error) { if (containersData.error || containersData.message) {
return <Container service={service} error={t("widget.api_error")} />; // containersData can be itself an error object e.g. if environment fails
return <Container service={service} error={ containersData?.error ?? containersData } />;
} }
const running = containersData.filter((c) => c.State === "running").length; const running = containersData.filter((c) => c.State === "running").length;

View File

@ -1,22 +1,41 @@
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCallback } from 'react';
import QueueEntry from "../../components/widgets/queue/queueEntry";
import Container from "components/services/widget/container"; import Container from "components/services/widget/container";
import Block from "components/services/widget/block"; import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api"; import useWidgetAPI from "utils/proxy/use-widget-api";
function getProgress(sizeLeft, size) {
return sizeLeft === 0 ? 100 : (1 - sizeLeft / size) * 100
}
export default function Component({ service }) { export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const { data: moviesData, error: moviesError } = useWidgetAPI(widget, "movie"); const { data: moviesData, error: moviesError } = useWidgetAPI(widget, "movie");
const { data: queuedData, error: queuedError } = useWidgetAPI(widget, "queue/status"); const { data: queuedData, error: queuedError } = useWidgetAPI(widget, "queue/status");
const { data: queueDetailsData, error: queueDetailsError } = useWidgetAPI(widget, "queue/details");
if (moviesError || queuedError) { const formatDownloadState = useCallback((downloadState) => {
const finalError = moviesError ?? queuedError; switch (downloadState) {
case "importPending":
return "import pending";
case "failedPending":
return "failed pending";
default:
return downloadState;
}
}, []);
if (moviesError || queuedError || queueDetailsError) {
const finalError = moviesError ?? queuedError ?? queueDetailsError;
return <Container service={service} error={finalError} />; return <Container service={service} error={finalError} />;
} }
if (!moviesData || !queuedData) { if (!moviesData || !queuedData || !queueDetailsData) {
return ( return (
<Container service={service}> <Container service={service}>
<Block label="radarr.wanted" /> <Block label="radarr.wanted" />
@ -27,12 +46,27 @@ export default function Component({ service }) {
); );
} }
const enableQueue = widget?.enableQueue && Array.isArray(queueDetailsData) && queueDetailsData.length > 0;
return ( return (
<Container service={service}> <>
<Block label="radarr.wanted" value={t("common.number", { value: moviesData.wanted })} /> <Container service={service}>
<Block label="radarr.missing" value={t("common.number", { value: moviesData.missing })} /> <Block label="radarr.wanted" value={t("common.number", { value: moviesData.wanted })} />
<Block label="radarr.queued" value={t("common.number", { value: queuedData.totalCount })} /> <Block label="radarr.missing" value={t("common.number", { value: moviesData.missing })} />
<Block label="radarr.movies" value={t("common.number", { value: moviesData.have })} /> <Block label="radarr.queued" value={t("common.number", { value: queuedData.totalCount })} />
</Container> <Block label="radarr.movies" value={t("common.number", { value: moviesData.have })} />
</Container>
{enableQueue &&
queueDetailsData.map((queueEntry) => (
<QueueEntry
progress={getProgress(queueEntry.sizeLeft, queueEntry.size)}
timeLeft={queueEntry.timeLeft}
title={moviesData.all.find((entry) => entry.id === queueEntry.movieId)?.title ?? t("radarr.unknown")}
activity={formatDownloadState(queueEntry.trackedDownloadState)}
key={`${queueEntry.movieId}-${queueEntry.sizeLeft}`}
/>
))
}
</>
); );
} }

Some files were not shown because too many files have changed in this diff Show More