mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-03 02:46:01 -07:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93763b7c10 | |||
| 6c551ace34 | |||
| 7886742d56 | |||
| a253861061 | |||
| edfb28ab5d | |||
| 3ab6b040b2 | |||
| 9cabb46bb7 | |||
| d7cc2d3f19 | |||
| b5084ba8b3 | |||
| dca0c9ab81 | |||
| d4fd14b724 | |||
| 600c9eee78 | |||
| 198c86fb49 | |||
| f711d290a7 | |||
| 342afa2215 | |||
| 27704f962e |
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
||||
- run: sudo apt-get install pngquant
|
||||
- name: Test Docs Build
|
||||
run: uv run --frozen zensical build --clean
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
||||
- run: sudo apt-get install pngquant
|
||||
- name: Build Docs
|
||||
run: uv run --frozen zensical build --clean
|
||||
|
||||
@@ -7,6 +7,10 @@ As of version v0.6.30 homepage supports adding your own custom css & javascript.
|
||||
|
||||
To add custom css simply edit the `custom.css` file under your config directory, similarly for javascript you would edit `custom.js`. You can then target elements in homepage with various classes / ids to customize things to your liking.
|
||||
|
||||
!!! warning
|
||||
|
||||
When Homepage authentication is enabled, `custom.css` remains publicly accessible so it can style the sign-in page. Do not include secrets or sensitive internal URLs in this file. `custom.js` remains protected and is only loaded after authentication.
|
||||
|
||||
You can also set a specific `id` for a service or bookmark to target with your custom css or javascript, e.g.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -74,6 +74,6 @@ For OIDC login (overrides password login):
|
||||
|
||||
Homepage grants access to any identity that the configured OIDC provider authorizes for this client. Configure client assignments, groups, or access policies at the identity provider. Homepage does not apply additional claim-based authorization.
|
||||
|
||||
All app pages and `/api` routes except `/api/healthcheck` will require a signed-in session. Static assets remain public.
|
||||
All app pages and `/api` routes except `/api/healthcheck` and `/api/config/custom.css` will require a signed-in session. Static assets remain public.
|
||||
|
||||
Configure your OIDC provider with the a callback URI like `https://homepage.example.com/api/auth/callback/homepage-oidc`.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authentik Widget Configuration
|
||||
|
||||
Learn more about [Authentik](https://github.com/goauthentik/authentik).
|
||||
|
||||
This widget reads the number of active users in the system, as well as logins for the last 24 hours.
|
||||
This widget reads the number of active users in the system, as well as logins for the last 24 hours, and authorizations when using widget version 2.
|
||||
|
||||
You will need to generate an API token for an existing user under `Admin Portal` > `Directory` > `Tokens & App passwords`.
|
||||
Make sure to set Intent to "API Token".
|
||||
@@ -15,7 +15,7 @@ The account you made the API token for also needs the following **Assigned globa
|
||||
- authentik Core -> Can view User (Model: User)
|
||||
- authentik Events -> Can view Event (Model: Event)
|
||||
|
||||
Allowed fields: `["users", "loginsLast24H", "failedLoginsLast24H"]`.
|
||||
Allowed fields: `["users", "loginsLast24H", "failedLoginsLast24H", "authorizationsLast24H"]` (`authorizationsLast24H` works with v2 only).
|
||||
|
||||
| Authentik Version | Homepage Widget Version |
|
||||
| ----------------- | ----------------------- |
|
||||
|
||||
@@ -12,5 +12,6 @@ widget:
|
||||
type: pulse
|
||||
url: http://pulse.host.or.ip:7655
|
||||
key: your-api-token # `monitoring:read` scope is required
|
||||
version: 2 # required for Pulse v6, defaults to 1
|
||||
fields: ["nodes", "vms", "lxcs"] # optional
|
||||
```
|
||||
|
||||
@@ -17,6 +17,8 @@ const compat = new FlatCompat({
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,jsx}"],
|
||||
|
||||
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
|
||||
|
||||
plugins: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Gebruikers",
|
||||
"loginsLast24H": "Aantekenings (24h)",
|
||||
"failedLoginsLast24H": "Mislukte Aantekenings (24h)"
|
||||
"failedLoginsLast24H": "Mislukte Aantekenings (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "GEH",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "المستخدمون",
|
||||
"loginsLast24H": "تسجيلات الدخول (٢٤س)",
|
||||
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)"
|
||||
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "الذاكرة",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Потребители",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Usuaris",
|
||||
"loginsLast24H": "Inicis de sessió (24h)",
|
||||
"failedLoginsLast24H": "Errors d'inici de sessió (24h)"
|
||||
"failedLoginsLast24H": "Errors d'inici de sessió (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Odhlásit se"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Chybí typ widgetu: {{type}}",
|
||||
@@ -75,13 +75,13 @@
|
||||
"no_data": "Nejsou k dispozici žádná data úložiště"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"jobs": "Úlohy",
|
||||
"stored": "Uloženo",
|
||||
"lastBackup": "Naposledy spuštěno",
|
||||
"nextRun": "Další spuštění",
|
||||
"running": "Běží",
|
||||
"warnings": "Upozornění",
|
||||
"errors": "Chyby"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -133,7 +133,7 @@
|
||||
"series": "Seriály",
|
||||
"episodes": "Epizody",
|
||||
"songs": "Skladby",
|
||||
"albums": "Albums"
|
||||
"albums": "Alba"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Uživatelé",
|
||||
"loginsLast24H": "Příhlášení (24h)",
|
||||
"failedLoginsLast24H": "Neúspěšná přihlášení (24h)"
|
||||
"failedLoginsLast24H": "Neúspěšná přihlášení (24h)",
|
||||
"authorizationsLast24H": "Autorizace (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Využití paměti",
|
||||
@@ -951,15 +952,15 @@
|
||||
"storage": "Úložiště"
|
||||
},
|
||||
"maintainerr": {
|
||||
"itemsHandled": "Items Handled",
|
||||
"episodesHandled": "Episodes Handled",
|
||||
"moviesHandled": "Movies Handled",
|
||||
"showsHandled": "Shows Handled",
|
||||
"seasonsHandled": "Seasons Handled",
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"itemsHandled": "Zpracované položky",
|
||||
"episodesHandled": "Zpracované epizody",
|
||||
"moviesHandled": "Zpracované filmy",
|
||||
"showsHandled": "Zpracované seriály",
|
||||
"seasonsHandled": "Zpracované série",
|
||||
"reclaimable": "Uvolnitelné",
|
||||
"movieReclaimable": "Uvolnitelné filmy",
|
||||
"showReclaimable": "Uvolnitelné seriály",
|
||||
"totalCapacity": "Celková kapacita"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Upozornění",
|
||||
@@ -1241,14 +1242,14 @@
|
||||
"steps": "Kroky"
|
||||
},
|
||||
"syncthing": {
|
||||
"connected": "Connected",
|
||||
"synced": "Synced",
|
||||
"errors": "Errors",
|
||||
"storage": "Storage"
|
||||
"connected": "Připojeno",
|
||||
"synced": "Synchronizováno",
|
||||
"errors": "Chyby",
|
||||
"storage": "Úložiště"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
"wanted": "Požadované",
|
||||
"queued": "Ve frontě",
|
||||
"leagues": "Ligy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Login (24 timer)",
|
||||
"failedLoginsLast24H": "Mislykkede logins (24 timer)"
|
||||
"failedLoginsLast24H": "Mislykkede logins (24 timer)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Benutzer",
|
||||
"loginsLast24H": "Anmeldungen (24 h)",
|
||||
"failedLoginsLast24H": "Fehlversuche (24 h)"
|
||||
"failedLoginsLast24H": "Fehlversuche (24 h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Συνδέσεις (24h)",
|
||||
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)"
|
||||
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
"series": "Series",
|
||||
"episodes": "Episodios",
|
||||
"songs": "Canciones",
|
||||
"albums": "Albums"
|
||||
"albums": "Álbumes"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Fuera de línea",
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Usuarios",
|
||||
"loginsLast24H": "Inicios de sesión (24h)",
|
||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)"
|
||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Kirjautumisia (24h)",
|
||||
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)"
|
||||
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Se déconnecter"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Type de widget manquant : {{type}}",
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Utilisateurs",
|
||||
"loginsLast24H": "Connexions (24 h)",
|
||||
"failedLoginsLast24H": "Connexions échouées (24 h)"
|
||||
"failedLoginsLast24H": "Connexions échouées (24 h)",
|
||||
"authorizationsLast24H": "Authentifications (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "משתמשים",
|
||||
"loginsLast24H": "כניסות (24h)",
|
||||
"failedLoginsLast24H": "כניסות שנכשלו (24h)"
|
||||
"failedLoginsLast24H": "כניסות שנכשלו (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "זיכרון",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Korisnici",
|
||||
"loginsLast24H": "Prijave (24 h)",
|
||||
"failedLoginsLast24H": "Neuspjele prijave (24 h)"
|
||||
"failedLoginsLast24H": "Neuspjele prijave (24 h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Felhasználók",
|
||||
"loginsLast24H": "Bejelentkezések (24 óra)",
|
||||
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)"
|
||||
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Login (24j)",
|
||||
"failedLoginsLast24H": "Login Gagal (24j)"
|
||||
"failedLoginsLast24H": "Login Gagal (24j)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Utenti",
|
||||
"loginsLast24H": "Accessi (24h)",
|
||||
"failedLoginsLast24H": "Accessi Falliti (24h)"
|
||||
"failedLoginsLast24H": "Accessi Falliti (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "ログイン (24時間)",
|
||||
"failedLoginsLast24H": "ログイン失敗(24時間)"
|
||||
"failedLoginsLast24H": "ログイン失敗(24時間)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "사용자",
|
||||
"loginsLast24H": "로그인 (24시간)",
|
||||
"failedLoginsLast24H": "실패한 로그인 (24시간)"
|
||||
"failedLoginsLast24H": "실패한 로그인 (24시간)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "메모리",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logmasuk (24j)",
|
||||
"failedLoginsLast24H": "Logmasuk Gagal (24j)"
|
||||
"failedLoginsLast24H": "Logmasuk Gagal (24j)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Gebruikers",
|
||||
"loginsLast24H": "Logins (24u)",
|
||||
"failedLoginsLast24H": "Mislukte Logins (24u)"
|
||||
"failedLoginsLast24H": "Mislukte Logins (24u)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "GEH",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Mislykket innlogginger (24t)"
|
||||
"failedLoginsLast24H": "Mislykket innlogginger (24t)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Wyloguj się"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Brakujący typ widżetu: {{type}}",
|
||||
@@ -133,7 +133,7 @@
|
||||
"series": "Seriale",
|
||||
"episodes": "Odcinki",
|
||||
"songs": "Piosenki",
|
||||
"albums": "Albums"
|
||||
"albums": "Albumy"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Offline",
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Użytkownicy",
|
||||
"loginsLast24H": "Logowania (24h)",
|
||||
"failedLoginsLast24H": "Nieudane logowania (24h)"
|
||||
"failedLoginsLast24H": "Nieudane logowania (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
|
||||
+428
-427
File diff suppressed because it is too large
Load Diff
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Usuários",
|
||||
"loginsLast24H": "Inícios de sessão (24h)",
|
||||
"failedLoginsLast24H": "Inícios de sessão falhados (24h)"
|
||||
"failedLoginsLast24H": "Inícios de sessão falhados (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Autentificări (24h)",
|
||||
"failedLoginsLast24H": "Conectări eșuate (24h)"
|
||||
"failedLoginsLast24H": "Conectări eșuate (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "сек"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Выход"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Отсутствует тип виджета: {{type}}",
|
||||
@@ -75,13 +75,13 @@
|
||||
"no_data": "Нет доступных данных о хранилище"
|
||||
},
|
||||
"duplicati": {
|
||||
"jobs": "Jobs",
|
||||
"stored": "Stored",
|
||||
"lastBackup": "Last Run",
|
||||
"nextRun": "Next Run",
|
||||
"running": "Running",
|
||||
"warnings": "Warnings",
|
||||
"errors": "Errors"
|
||||
"jobs": "Работы",
|
||||
"stored": "Сохранено",
|
||||
"lastBackup": "Последний запуск",
|
||||
"nextRun": "Следующий запуск",
|
||||
"running": "Запущено",
|
||||
"warnings": "Предупреждения",
|
||||
"errors": "Ошибки"
|
||||
},
|
||||
"docker": {
|
||||
"rx": "RX",
|
||||
@@ -133,7 +133,7 @@
|
||||
"series": "Сериалы",
|
||||
"episodes": "Эпизоды",
|
||||
"songs": "Песни",
|
||||
"albums": "Albums"
|
||||
"albums": "Альбомы"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Не в сети",
|
||||
@@ -235,17 +235,17 @@
|
||||
"rutorrent": {
|
||||
"active": "Активно",
|
||||
"upload": "Отдача",
|
||||
"download": "Скачивание"
|
||||
"download": "Загрузка"
|
||||
},
|
||||
"transmission": {
|
||||
"download": "Скачивание",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"download": "Скачивание",
|
||||
"upload": "Загрузка",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
@@ -258,8 +258,8 @@
|
||||
"invalid": "Некорректный"
|
||||
},
|
||||
"deluge": {
|
||||
"download": "Скачивание",
|
||||
"upload": "Загрузка",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
@@ -268,8 +268,8 @@
|
||||
"cachemissbytes": "Мисс байты кэша"
|
||||
},
|
||||
"downloadstation": {
|
||||
"download": "Скачивание",
|
||||
"upload": "Загрузка",
|
||||
"download": "Загрузка",
|
||||
"upload": "Отдача",
|
||||
"leech": "Лич",
|
||||
"seed": "Сид"
|
||||
},
|
||||
@@ -334,8 +334,8 @@
|
||||
"latency": "Задержка"
|
||||
},
|
||||
"speedtest": {
|
||||
"upload": "Загрузка",
|
||||
"download": "Скачивание",
|
||||
"upload": "Отдача",
|
||||
"download": "Загрузка",
|
||||
"ping": "Пинг"
|
||||
},
|
||||
"portainer": {
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Пользователи",
|
||||
"loginsLast24H": "Входы (24ч)",
|
||||
"failedLoginsLast24H": "Неудачные входы (24ч)"
|
||||
"failedLoginsLast24H": "Неудачные входы (24ч)",
|
||||
"authorizationsLast24H": "Входы (24ч)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Память",
|
||||
@@ -480,9 +481,9 @@
|
||||
"vms": "Виртуальные машины"
|
||||
},
|
||||
"pulse": {
|
||||
"nodes": "Nodes",
|
||||
"vms": "VMs",
|
||||
"lxcs": "LXCs"
|
||||
"nodes": "Узлы",
|
||||
"vms": "Виртуальные машины",
|
||||
"lxcs": "Контейнеры LXC"
|
||||
},
|
||||
"glances": {
|
||||
"cpu": "ЦП",
|
||||
@@ -581,7 +582,7 @@
|
||||
"up": "В сети",
|
||||
"pending": "Ожидают",
|
||||
"down": "Не в сети",
|
||||
"ok": "Ok"
|
||||
"ok": "OK"
|
||||
},
|
||||
"healthchecks": {
|
||||
"new": "Новый",
|
||||
@@ -649,7 +650,7 @@
|
||||
"total": "Всего"
|
||||
},
|
||||
"pangolin": {
|
||||
"orgs": "Orgs",
|
||||
"orgs": "Организации",
|
||||
"sites": "Сайты",
|
||||
"resources": "Ресурсы",
|
||||
"targets": "Цели",
|
||||
@@ -687,7 +688,7 @@
|
||||
"limit": "Лимит"
|
||||
},
|
||||
"opnsense": {
|
||||
"cpu": "ЦП",
|
||||
"cpu": "Нагрузка ЦП",
|
||||
"memory": "Активно ОЗУ",
|
||||
"wanUpload": "WAN Загрузка",
|
||||
"wanDownload": "WAN скачивание"
|
||||
@@ -822,7 +823,7 @@
|
||||
"gross_percent_today": "Сегодня",
|
||||
"gross_percent_1y": "Один год",
|
||||
"gross_percent_max": "Все время",
|
||||
"net_worth": "Net Worth"
|
||||
"net_worth": "Общая стоимость"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"podcasts": "Подкасты",
|
||||
@@ -951,15 +952,15 @@
|
||||
"storage": "Хранилище"
|
||||
},
|
||||
"maintainerr": {
|
||||
"itemsHandled": "Items Handled",
|
||||
"episodesHandled": "Episodes Handled",
|
||||
"moviesHandled": "Movies Handled",
|
||||
"showsHandled": "Shows Handled",
|
||||
"seasonsHandled": "Seasons Handled",
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"itemsHandled": "Элементы обрабатываются",
|
||||
"episodesHandled": "Серии обрабатываются",
|
||||
"moviesHandled": "Фильмы обрабатываются",
|
||||
"showsHandled": "Шоу обрабатываются",
|
||||
"seasonsHandled": "Сезоны обрабатываются",
|
||||
"reclaimable": "Восстановленные",
|
||||
"movieReclaimable": "Восстановленный фильм",
|
||||
"showReclaimable": "Восстановленное шоу",
|
||||
"totalCapacity": "Общая ёмкость"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Предупреждения",
|
||||
@@ -968,7 +969,7 @@
|
||||
"ntfy": {
|
||||
"title": "Название",
|
||||
"priority": "Приоритет",
|
||||
"lastReceived": "Last Received",
|
||||
"lastReceived": "Последние полученные",
|
||||
"message": "Сообщение",
|
||||
"tags": "Теги",
|
||||
"none": "Отсутствует",
|
||||
@@ -1105,7 +1106,7 @@
|
||||
"apps": "Приложения",
|
||||
"synced": "Синхронизированные",
|
||||
"outOfSync": "Не синхронизированные",
|
||||
"healthy": "Healthy",
|
||||
"healthy": "Здоров",
|
||||
"degraded": "Деградированные",
|
||||
"progressing": "Выполняются",
|
||||
"missing": "Отсутствует",
|
||||
@@ -1152,18 +1153,18 @@
|
||||
"other": "Другое"
|
||||
},
|
||||
"checkmk": {
|
||||
"serviceErrors": "Service issues",
|
||||
"hostErrors": "Host issues"
|
||||
"serviceErrors": "Проблемы сервиса",
|
||||
"hostErrors": "Проблемы узла"
|
||||
},
|
||||
"komodo": {
|
||||
"total": "Всего",
|
||||
"running": "Запущено",
|
||||
"stopped": "Остановлено",
|
||||
"down": "Не в сети",
|
||||
"unhealthy": "Unhealthy",
|
||||
"unhealthy": "Неисправен",
|
||||
"unknown": "Неизвестно",
|
||||
"servers": "Серверы",
|
||||
"stacks": "Stacks",
|
||||
"stacks": "Стеки",
|
||||
"containers": "Контейнеры"
|
||||
},
|
||||
"filebrowser": {
|
||||
@@ -1186,7 +1187,7 @@
|
||||
"DISABLE_DISK": "Диск отключен",
|
||||
"SWAP_DSBL": "Swap отключён",
|
||||
"INVALID_EXPANSION": "Неверное Расширение",
|
||||
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
|
||||
"PARITY_NOT_BIGGEST": "Четность не наибольшая",
|
||||
"TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков",
|
||||
"NEW_DISK_TOO_SMALL": "Новый диск слишком мал",
|
||||
"NO_DATA_DISKS": "Нет дисков данных",
|
||||
@@ -1195,18 +1196,18 @@
|
||||
"cpu": "ЦП",
|
||||
"memoryUsed": "Использовано ОЗУ",
|
||||
"memoryAvailable": "Доступная память",
|
||||
"arrayUsed": "Array Used",
|
||||
"arrayFree": "Array Free",
|
||||
"arrayUsed": "Используемый массив",
|
||||
"arrayFree": "Свободный массив",
|
||||
"poolUsed": "{{pool}} Использовано",
|
||||
"poolFree": "{{pool}} Свободно"
|
||||
},
|
||||
"backrest": {
|
||||
"num_plans": "Plans",
|
||||
"num_success_30": "Successes",
|
||||
"num_plans": "Планы",
|
||||
"num_success_30": "Успешные",
|
||||
"num_failure_30": "Ошибки",
|
||||
"num_success_latest": "Succeeding",
|
||||
"num_failure_latest": "Failing",
|
||||
"bytes_added_30": "Bytes Added"
|
||||
"num_success_latest": "Успешно",
|
||||
"num_failure_latest": "Неудачные",
|
||||
"bytes_added_30": "Байты добавлены"
|
||||
},
|
||||
"yourspotify": {
|
||||
"songs": "Треков",
|
||||
@@ -1235,20 +1236,20 @@
|
||||
"environment_not_found": "Среда не найдена"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"eaten": "Eaten",
|
||||
"burned": "Burned",
|
||||
"remaining": "Remaining",
|
||||
"steps": "Steps"
|
||||
"eaten": "Съедено",
|
||||
"burned": "Сожжено",
|
||||
"remaining": "Осталось",
|
||||
"steps": "Шаги"
|
||||
},
|
||||
"syncthing": {
|
||||
"connected": "Connected",
|
||||
"synced": "Synced",
|
||||
"errors": "Errors",
|
||||
"storage": "Storage"
|
||||
"connected": "Подключено",
|
||||
"synced": "Синхронизировано",
|
||||
"errors": "Ошибки",
|
||||
"storage": "Хранилище"
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"leagues": "Leagues"
|
||||
"wanted": "Требуется",
|
||||
"queued": "В очереди",
|
||||
"leagues": "Лиги"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"seconds": "s"
|
||||
},
|
||||
"auth": {
|
||||
"signout": "Sign out"
|
||||
"signout": "Odhlásiť sa"
|
||||
},
|
||||
"widget": {
|
||||
"missing_type": "Chýba typ widgetu: {{type}}",
|
||||
@@ -133,7 +133,7 @@
|
||||
"series": "Seriálov",
|
||||
"episodes": "Epizód",
|
||||
"songs": "Skladieb",
|
||||
"albums": "Albums"
|
||||
"albums": "Albumov"
|
||||
},
|
||||
"esphome": {
|
||||
"offline": "Nedostupné",
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Používateľov",
|
||||
"loginsLast24H": "Prihlás. (24 hod.)",
|
||||
"failedLoginsLast24H": "Neúspešné prihlás. (24 hod.)"
|
||||
"failedLoginsLast24H": "Neúspešné prihlás. (24 hod.)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "RAM",
|
||||
@@ -959,7 +960,7 @@
|
||||
"reclaimable": "Reclaimable",
|
||||
"movieReclaimable": "Movie Reclaimable",
|
||||
"showReclaimable": "Show Reclaimable",
|
||||
"totalCapacity": "Total Capacity"
|
||||
"totalCapacity": "Celková kapacita"
|
||||
},
|
||||
"netdata": {
|
||||
"warnings": "Upozornenia",
|
||||
@@ -1248,7 +1249,7 @@
|
||||
},
|
||||
"sportarr": {
|
||||
"wanted": "Wanted",
|
||||
"queued": "Queued",
|
||||
"queued": "V poradí",
|
||||
"leagues": "Leagues"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Prijave (24h)",
|
||||
"failedLoginsLast24H": "Neveljavne prijave (24h)"
|
||||
"failedLoginsLast24H": "Neveljavne prijave (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Корисника",
|
||||
"loginsLast24H": "Пријаве (24ч)",
|
||||
"failedLoginsLast24H": "Неуспешне пријаве (24ч)"
|
||||
"failedLoginsLast24H": "Неуспешне пријаве (24ч)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Меморија",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Inloggningar (24h)",
|
||||
"failedLoginsLast24H": "Misslyckade inloggningar (24h)"
|
||||
"failedLoginsLast24H": "Misslyckade inloggningar (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "లాగిన్లు (24గం)",
|
||||
"failedLoginsLast24H": "విఫలమైన లాగిన్లు (24గం)"
|
||||
"failedLoginsLast24H": "విఫలమైన లాగిన్లు (24గం)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Kullanıcılar",
|
||||
"loginsLast24H": "Girişler (24 Saat)",
|
||||
"failedLoginsLast24H": "Başarısız Girişler (24 Saat)"
|
||||
"failedLoginsLast24H": "Başarısız Girişler (24 Saat)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Bellek",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Користувачі",
|
||||
"loginsLast24H": "Вхід (протягом доби)",
|
||||
"failedLoginsLast24H": "Невдалі входи (протягом доби)"
|
||||
"failedLoginsLast24H": "Невдалі входи (протягом доби)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "Пам'ять",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "Logins (24h)",
|
||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
||||
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "登錄( 24小时)",
|
||||
"failedLoginsLast24H": "登錄失敗( 24鐘頭)"
|
||||
"failedLoginsLast24H": "登錄失敗( 24鐘頭)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "登录 (24h)",
|
||||
"failedLoginsLast24H": "登录失败 (24h)"
|
||||
"failedLoginsLast24H": "登录失败 (24h)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "内存",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"authentik": {
|
||||
"users": "Users",
|
||||
"loginsLast24H": "登入 (過去 24 小時)",
|
||||
"failedLoginsLast24H": "登入失敗 (過去 24 小時)"
|
||||
"failedLoginsLast24H": "登入失敗 (過去 24 小時)",
|
||||
"authorizationsLast24H": "Auths (24h)"
|
||||
},
|
||||
"proxmox": {
|
||||
"mem": "MEM",
|
||||
|
||||
@@ -3,21 +3,27 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getSettingsMock } = vi.hoisted(() => ({
|
||||
const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({
|
||||
getSettingsMock: vi.fn(),
|
||||
authOptionsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/config", () => ({
|
||||
getSettings: getSettingsMock,
|
||||
}));
|
||||
|
||||
vi.mock("pages/api/auth/[...nextauth]", () => ({
|
||||
get authOptions() {
|
||||
return authOptionsMock();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/router", () => ({
|
||||
useRouter: () => ({
|
||||
query: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getProviders } from "next-auth/react";
|
||||
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
||||
|
||||
describe("pages/auth/signin", () => {
|
||||
@@ -33,7 +39,7 @@ describe("pages/auth/signin", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
|
||||
expect(screen.getByText("Authentication error")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
@@ -61,7 +67,7 @@ describe("pages/auth/signin", () => {
|
||||
});
|
||||
|
||||
it("getServerSideProps returns providers and only public sign-in settings", async () => {
|
||||
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
|
||||
authOptionsMock.mockReturnValueOnce({ providers: [{ id: "foo", name: "Foo", type: "oauth" }] });
|
||||
getSettingsMock.mockReturnValueOnce({
|
||||
theme: "dark",
|
||||
color: "slate",
|
||||
@@ -79,11 +85,10 @@ describe("pages/auth/signin", () => {
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(getProviders).toHaveBeenCalled();
|
||||
expect(getSettingsMock).toHaveBeenCalled();
|
||||
expect(res).toEqual({
|
||||
props: {
|
||||
providers: { foo: { id: "foo", name: "Foo" } },
|
||||
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
|
||||
settings: {
|
||||
theme: "dark",
|
||||
color: "slate",
|
||||
@@ -96,4 +101,53 @@ describe("pages/auth/signin", () => {
|
||||
expect(res.props.settings).not.toHaveProperty("providers");
|
||||
expect(res.props.settings).not.toHaveProperty("layout");
|
||||
});
|
||||
|
||||
it("getServerSideProps falls back to no providers when auth options fail to load", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
authOptionsMock.mockImplementationOnce(() => {
|
||||
throw new Error("Homepage auth is enabled but HOMEPAGE_EXTERNAL_URL (or NEXTAUTH_URL) is missing.");
|
||||
});
|
||||
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(res.props.providers).toEqual({});
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("getServerSideProps passes only id, name and type from each provider", async () => {
|
||||
authOptionsMock.mockReturnValueOnce({
|
||||
providers: [
|
||||
{
|
||||
id: "homepage-oidc",
|
||||
name: "Homepage OIDC",
|
||||
type: "oauth",
|
||||
issuer: "https://oidc.example",
|
||||
clientId: "canary-client-id",
|
||||
clientSecret: "canary-client-secret",
|
||||
wellKnown: "https://oidc.example/.well-known/openid-configuration",
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
profile: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
name: "Password",
|
||||
type: "credentials",
|
||||
credentials: { password: { label: "Password", type: "password" } },
|
||||
authorize: () => null,
|
||||
},
|
||||
],
|
||||
});
|
||||
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
|
||||
|
||||
const res = await getServerSideProps({});
|
||||
|
||||
expect(res.props.providers).toEqual({
|
||||
"homepage-oidc": { id: "homepage-oidc", name: "Homepage OIDC", type: "oauth" },
|
||||
credentials: { id: "credentials", name: "Password", type: "credentials" },
|
||||
});
|
||||
// These props get serialized into the sign-in page, which is unauthenticated
|
||||
expect(JSON.stringify(res.props)).not.toMatch(/canary-client-secret|canary-client-id|oidc\.example/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ export default class ErrorBoundary extends React.Component {
|
||||
|
||||
// You can also log error messages to an error reporting service here
|
||||
if (error || errorInfo) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("component error: %s, info: %s", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
let descriptionMatch;
|
||||
if (searchDescriptions) {
|
||||
descriptionMatch = r.description?.toLowerCase().includes(searchString);
|
||||
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; // eslint-disable-line no-param-reassign
|
||||
r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch;
|
||||
}
|
||||
return nameMatch || descriptionMatch;
|
||||
});
|
||||
@@ -244,7 +244,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
||||
<span>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === searchString.toLowerCase() ? (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<span key={`${searchString}_${i}`} className="bg-theme-300/10">
|
||||
{part}
|
||||
</span>
|
||||
|
||||
@@ -13,13 +13,13 @@ export default function Error({ error }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (typeof error === "string") {
|
||||
error = { message: error }; // eslint-disable-line no-param-reassign
|
||||
error = { message: error };
|
||||
} else if (typeof error === "number") {
|
||||
error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign
|
||||
error = { message: `Error ${error}` };
|
||||
}
|
||||
|
||||
if (error?.data?.error) {
|
||||
error = error.data.error; // eslint-disable-line no-param-reassign
|
||||
error = error.data.error;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,6 @@ import Resource from "../widget/resource";
|
||||
|
||||
export default function Network({ options, refresh = 1500 }) {
|
||||
const { t } = useTranslation();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (options.network === true) options.network = "default";
|
||||
|
||||
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@headlessui/react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { BiLogoBing } from "react-icons/bi";
|
||||
import { FiSearch } from "react-icons/fi";
|
||||
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
|
||||
@@ -57,12 +57,12 @@ export const searchProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
function getAvailableProviderIds(options) {
|
||||
if (options.provider && Array.isArray(options.provider)) {
|
||||
return options.provider.filter((value) => searchProviders.hasOwnProperty(value));
|
||||
function getAvailableProviderIds(provider) {
|
||||
if (provider && Array.isArray(provider)) {
|
||||
return provider.filter((value) => searchProviders.hasOwnProperty(value));
|
||||
}
|
||||
if (options.provider && searchProviders[options.provider]) {
|
||||
return [options.provider];
|
||||
if (provider && searchProviders[provider]) {
|
||||
return [provider];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -82,7 +82,8 @@ export function getStoredProvider() {
|
||||
export default function Search({ options }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableProviderIds = getAvailableProviderIds(options) ?? [];
|
||||
// options is a fresh object each render, so memo on provider itself
|
||||
const availableProviderIds = useMemo(() => getAvailableProviderIds(options.provider) ?? [], [options.provider]);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
|
||||
@@ -184,7 +185,6 @@ export default function Search({ options }) {
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={options.focus}
|
||||
onBlur={(e) => e.preventDefault()}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
|
||||
@@ -14,7 +14,6 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
export default function Widget({ options }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// eslint-disable-next-line no-param-reassign, no-multi-assign
|
||||
options.service_group = options.service_name = "unifi_console";
|
||||
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
|
||||
|
||||
@@ -39,8 +38,8 @@ export default function Widget({ options }) {
|
||||
const lan = defaultSite.health.find((h) => h.subsystem === "lan");
|
||||
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
|
||||
[wan, lan, wlan].forEach((s) => {
|
||||
s.up = s.status === "ok"; // eslint-disable-line no-param-reassign
|
||||
s.show = s.status !== "unknown"; // eslint-disable-line no-param-reassign
|
||||
s.up = s.status === "ok";
|
||||
s.show = s.status !== "unknown";
|
||||
});
|
||||
const name = wan.gw_name ?? defaultSite.desc;
|
||||
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
|
||||
|
||||
@@ -10,7 +10,6 @@ import WidgetIcon from "./widget_icon";
|
||||
export function getAllClasses(options, additionalClassNames = "") {
|
||||
if (options?.style?.header === "boxedWidgets") {
|
||||
if (options?.style?.cardBlur !== undefined) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
additionalClassNames = [
|
||||
additionalClassNames,
|
||||
`backdrop-blur${options.style.cardBlur.length ? "-" : ""}${options.style.cardBlur}`,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { applyNextAuthEnv } from "utils/env";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
applyNextAuthEnv();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("instrumentation", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.NEXTAUTH_SECRET;
|
||||
delete process.env.NEXTAUTH_URL;
|
||||
delete process.env.HOMEPAGE_AUTH_SECRET;
|
||||
delete process.env.HOMEPAGE_EXTERNAL_URL;
|
||||
process.env.NEXT_RUNTIME = "nodejs";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("maps HOMEPAGE_* auth envs to their NextAuth equivalents", async () => {
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
register();
|
||||
|
||||
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
|
||||
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
|
||||
});
|
||||
|
||||
it("does not override explicitly configured NextAuth envs", async () => {
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
|
||||
process.env.NEXTAUTH_SECRET = "explicit-secret";
|
||||
process.env.NEXTAUTH_URL = "https://explicit.example";
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
register();
|
||||
|
||||
expect(process.env.NEXTAUTH_SECRET).toBe("explicit-secret");
|
||||
expect(process.env.NEXTAUTH_URL).toBe("https://explicit.example");
|
||||
});
|
||||
|
||||
it("is a no-op outside the node runtime", async () => {
|
||||
process.env.NEXT_RUNTIME = "edge";
|
||||
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
|
||||
const { register } = await import("./instrumentation");
|
||||
|
||||
register();
|
||||
|
||||
expect(process.env.NEXTAUTH_URL).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -33,7 +33,8 @@ export async function middleware(req) {
|
||||
}
|
||||
|
||||
const pathname = new URL(req.url).pathname;
|
||||
if (authEnabled && !pathname.startsWith("/api/healthcheck")) {
|
||||
const isPublicAuthPath = pathname.startsWith("/api/healthcheck") || pathname === "/api/config/custom.css";
|
||||
if (authEnabled && !isPublicAuthPath) {
|
||||
// The MCP API handler authorizes both bearer tokens and Homepage sessions.
|
||||
if (pathname === "/api/mcp") {
|
||||
return withPrivateCache(NextResponse.next());
|
||||
|
||||
@@ -100,6 +100,31 @@ describe("middleware", () => {
|
||||
expect(res.type).toBe("next");
|
||||
});
|
||||
|
||||
it("allows custom CSS without auth so it can style the signin page", async () => {
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||
|
||||
const middleware = await loadMiddleware();
|
||||
const res = await middleware(createReq("localhost:3000", "http://localhost:3000/api/config/custom.css"));
|
||||
|
||||
expect(getToken).not.toHaveBeenCalled();
|
||||
expect(NextResponse.next).toHaveBeenCalled();
|
||||
expect(res.type).toBe("next");
|
||||
});
|
||||
|
||||
it("continues to require auth for custom JavaScript", async () => {
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||
|
||||
getToken.mockResolvedValueOnce(null);
|
||||
|
||||
const middleware = await loadMiddleware();
|
||||
const res = await middleware(createReq("localhost:3000", "http://localhost:3000/api/config/custom.js"));
|
||||
|
||||
expect(getToken).toHaveBeenCalled();
|
||||
expect(res.type).toBe("redirect");
|
||||
});
|
||||
|
||||
it.each(["false", "0", "no", "off", ""])("treats HOMEPAGE_AUTH_ENABLED=%j as disabled", async (value) => {
|
||||
process.env.HOMEPAGE_AUTH_ENABLED = value;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { appWithTranslation } from "next-i18next/pages";
|
||||
import Head from "next/head";
|
||||
@@ -13,7 +12,6 @@ import { ThemeProvider } from "utils/contexts/theme";
|
||||
|
||||
import nextI18nextConfig from "../../next-i18next.config";
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const tailwindSafelist = [
|
||||
// TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147
|
||||
"backdrop-blur",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import NextAuth from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
|
||||
import { isAuthEnabled } from "utils/env";
|
||||
import { applyNextAuthEnv, isAuthEnabled } from "utils/env";
|
||||
import createLogger from "utils/logger";
|
||||
|
||||
const MIN_AUTH_SECRET_LENGTH = 32;
|
||||
@@ -12,20 +12,13 @@ const authEnabled = isAuthEnabled();
|
||||
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
|
||||
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
|
||||
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
|
||||
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
|
||||
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
|
||||
const homepageAuthPasswordDigest = homepageAuthPassword
|
||||
? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
|
||||
: null;
|
||||
|
||||
// Map HOMEPAGE_* envs to what NextAuth expects
|
||||
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
|
||||
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
|
||||
}
|
||||
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
|
||||
process.env.NEXTAUTH_URL = homepageExternalUrl;
|
||||
}
|
||||
// Also done in instrumentation.js
|
||||
applyNextAuthEnv();
|
||||
|
||||
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
|
||||
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import { getProviders, signIn } from "next-auth/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BiShieldQuarter } from "react-icons/bi";
|
||||
@@ -94,10 +94,8 @@ export default function SignIn({ providers, settings }) {
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
|
||||
<BiShieldQuarter className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
|
||||
Authentication not configured
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
|
||||
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">Authentication error</h1>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">Auth is disabled or misconfigured.</p>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
@@ -202,7 +200,16 @@ export default function SignIn({ providers, settings }) {
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const providers = await getProviders();
|
||||
// Avoid getProviders() fetch
|
||||
let providers = {};
|
||||
try {
|
||||
// Dynamic so a bad config throws in here rather than at page load
|
||||
const { authOptions } = await import("pages/api/auth/[...nextauth]");
|
||||
providers = Object.fromEntries(authOptions.providers.map(({ id, name, type }) => [id, { id, name, type }]));
|
||||
} catch (e) {
|
||||
console.error("Unable to load auth providers: %s", e.message);
|
||||
}
|
||||
|
||||
const homepageSettings = getSettings();
|
||||
const settings = Object.fromEntries(
|
||||
PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import classNames from "classnames";
|
||||
import BookmarksGroup from "components/bookmarks/group";
|
||||
import ErrorBoundary from "components/errorboundry";
|
||||
@@ -175,7 +174,7 @@ function Index({ initialSettings, fallback }) {
|
||||
</div>
|
||||
<div className="p-2 text-theme-100 dark:text-theme-200">
|
||||
<pre className="opacity-50 font-bold pb-2">
|
||||
Reason: "{error.reason}" at line {error.mark?.line}
|
||||
Reason: "{error.reason}" at line {error.mark?.line}
|
||||
</pre>
|
||||
<pre className="font-italic">Check logs for details.</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { SettingsContext } from "utils/contexts/settings";
|
||||
|
||||
export function renderWithProviders(ui, { settings = {} } = {}) {
|
||||
|
||||
@@ -589,6 +589,7 @@ export function cleanServiceGroups(groups) {
|
||||
"grafana",
|
||||
"gluetun",
|
||||
"vikunja",
|
||||
"pulse",
|
||||
].includes(type)
|
||||
) {
|
||||
widget.version = parseVersionForUrl(version);
|
||||
|
||||
@@ -266,6 +266,7 @@ describe("utils/config/service-helpers", () => {
|
||||
widgets: [
|
||||
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
|
||||
{ type: "beszel", version: "2", systemId: "sys" },
|
||||
{ type: "pulse", version: "2" },
|
||||
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
|
||||
{ type: "crowdsec", limit24h: "true" },
|
||||
{ type: "docker", server: "docker-local", container: "c1" },
|
||||
@@ -345,6 +346,7 @@ describe("utils/config/service-helpers", () => {
|
||||
expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
|
||||
);
|
||||
expect(widgets.find((w) => w.type === "beszel")).toEqual(expect.objectContaining({ version: 2, systemId: "sys" }));
|
||||
expect(widgets.find((w) => w.type === "pulse")).toEqual(expect.objectContaining({ version: 2 }));
|
||||
expect(widgets.find((w) => w.type === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
|
||||
expect(widgets.find((w) => w.type === "docker")).toEqual(
|
||||
expect.objectContaining({ server: "docker-local", container: "c1" }),
|
||||
|
||||
@@ -32,7 +32,6 @@ export function ColorProvider({ initialTheme, children }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,7 +33,6 @@ export function ThemeProvider({ initialTheme, children }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
export function isAuthEnabled() {
|
||||
return process.env.HOMEPAGE_AUTH_ENABLED === "true";
|
||||
}
|
||||
|
||||
// Frozen at module load, so map them before anything imports it
|
||||
export function applyNextAuthEnv() {
|
||||
if (!process.env.NEXTAUTH_SECRET && process.env.HOMEPAGE_AUTH_SECRET) {
|
||||
process.env.NEXTAUTH_SECRET = process.env.HOMEPAGE_AUTH_SECRET;
|
||||
}
|
||||
if (!process.env.NEXTAUTH_URL && process.env.HOMEPAGE_EXTERNAL_URL) {
|
||||
process.env.NEXTAUTH_URL = process.env.HOMEPAGE_EXTERNAL_URL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ import createLogger from "utils/logger";
|
||||
const logger = createLogger("resource-helpers");
|
||||
const kc = getKubeConfig();
|
||||
|
||||
const getSchemaFromGateway = async (parentRef) => {
|
||||
const getSchemaFromGateway = async (parentRef, routeNamespace) => {
|
||||
const crd = kc.makeApiClient(CustomObjectsApi);
|
||||
const schema = await crd
|
||||
.getNamespacedCustomObject({
|
||||
group: HTTPROUTE_API_GROUP,
|
||||
version: HTTPROUTE_API_VERSION,
|
||||
namespace: parentRef.namespace,
|
||||
// parentRef namespace is optional, defaults to the route's namespace
|
||||
namespace: parentRef.namespace ?? routeNamespace,
|
||||
plural: "gateways",
|
||||
name: parentRef.name,
|
||||
})
|
||||
@@ -31,7 +32,7 @@ const getSchemaFromGateway = async (parentRef) => {
|
||||
return listener.protocol.toLowerCase();
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Error getting gateways: %d %s %s", error.statusCode, error.body, error.response);
|
||||
logger.error("Error getting gateways: %s", error.body ?? error.message);
|
||||
logger.debug(error);
|
||||
|
||||
return "http";
|
||||
@@ -48,7 +49,7 @@ async function getUrlFromHttpRoute(resource) {
|
||||
if (resource.spec.rules[0].matches[0].path.type !== "RegularExpression") {
|
||||
const urlHost = resource.spec.hostnames[0];
|
||||
const urlPath = resource.spec.rules[0].matches[0].path.value;
|
||||
const urlSchema = await getSchemaFromGateway(resource.spec.parentRefs[0]);
|
||||
const urlSchema = await getSchemaFromGateway(resource.spec.parentRefs[0], resource.metadata.namespace);
|
||||
url = `${urlSchema}://${urlHost}${urlPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,36 @@ describe("utils/kubernetes/resource-helpers", () => {
|
||||
expect(service.href).toBe("https://example.com/r");
|
||||
});
|
||||
|
||||
it("falls back to the route namespace when the parentRef omits one", async () => {
|
||||
const kc = getKubeConfig();
|
||||
const crd = kc.makeApiClient();
|
||||
|
||||
const base = "gethomepage.dev";
|
||||
const resource = {
|
||||
kind: "HTTPRoute",
|
||||
metadata: {
|
||||
name: "route",
|
||||
namespace: "ns",
|
||||
annotations: {
|
||||
[`${base}/enabled`]: "true",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
hostnames: ["example.com"],
|
||||
parentRefs: [{ name: "gw", sectionName: "web" }],
|
||||
rules: [
|
||||
{
|
||||
matches: [{ path: { type: "PathPrefix", value: "/r" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const service = await constructedServiceFromResource(resource);
|
||||
expect(crd.getNamespacedCustomObject).toHaveBeenCalledWith(expect.objectContaining({ namespace: "ns" }));
|
||||
expect(service.href).toBe("https://example.com/r");
|
||||
});
|
||||
|
||||
it("falls back to http when the gateway listener protocol cannot be resolved", async () => {
|
||||
const kc = getKubeConfig();
|
||||
const crd = kc.makeApiClient();
|
||||
@@ -169,10 +199,41 @@ describe("utils/kubernetes/resource-helpers", () => {
|
||||
|
||||
const service = await constructedServiceFromResource(resource);
|
||||
expect(service.href).toBe("http://example.com/r");
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith("Error getting gateways: %s", "boom");
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs the message for gateway errors that carry no response body", async () => {
|
||||
const kc = getKubeConfig();
|
||||
const crd = kc.makeApiClient();
|
||||
crd.getNamespacedCustomObject.mockRejectedValueOnce(new Error("Required parameter namespace was null"));
|
||||
|
||||
const base = "gethomepage.dev";
|
||||
const resource = {
|
||||
kind: "HTTPRoute",
|
||||
metadata: {
|
||||
name: "route",
|
||||
namespace: "ns",
|
||||
annotations: {
|
||||
[`${base}/enabled`]: "true",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
hostnames: ["example.com"],
|
||||
parentRefs: [{ namespace: "ns", name: "gw", sectionName: "web" }],
|
||||
rules: [
|
||||
{
|
||||
matches: [{ path: { type: "PathPrefix", value: "/r" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const service = await constructedServiceFromResource(resource);
|
||||
expect(service.href).toBe("http://example.com/r");
|
||||
expect(logger.error).toHaveBeenCalledWith("Error getting gateways: %s", "Required parameter namespace was null");
|
||||
});
|
||||
|
||||
it("logs and recovers when environment substitution yields invalid json", async () => {
|
||||
substituteEnvironmentVars.mockImplementationOnce(() => "{bad json");
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ export default function Component({ service }) {
|
||||
widget.fields = widget.fields.slice(0, MAX_FIELDS);
|
||||
}
|
||||
|
||||
if (widget?.env == null || widget.env === "") {
|
||||
const envNotSet = widget.env == null || widget.env === "";
|
||||
|
||||
const { data: containers, error: containersError } = useWidgetAPI(widget, envNotSet ? "" : "containers");
|
||||
const { data: images, error: imagesError } = useWidgetAPI(widget, envNotSet ? "" : "images");
|
||||
const { data: updates, error: updatesError } = useWidgetAPI(widget, envNotSet ? "" : "updates");
|
||||
|
||||
if (envNotSet) {
|
||||
return <Container service={service} error={t("arcane.environment_required")} />;
|
||||
}
|
||||
|
||||
const { data: containers, error: containersError } = useWidgetAPI(widget, "containers");
|
||||
const { data: images, error: imagesError } = useWidgetAPI(widget, "images");
|
||||
const { data: updates, error: updatesError } = useWidgetAPI(widget, "updates");
|
||||
|
||||
const error =
|
||||
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
|
||||
if (error) {
|
||||
|
||||
@@ -20,11 +20,15 @@ describe("widgets/arcane/component", () => {
|
||||
});
|
||||
|
||||
it("shows an environment required error when env is missing", () => {
|
||||
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "arcane" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(useWidgetAPI).not.toHaveBeenCalled();
|
||||
// hooks always run; the empty endpoint is what skips the request
|
||||
expect(useWidgetAPI).toHaveBeenCalledTimes(3);
|
||||
useWidgetAPI.mock.calls.forEach((call) => expect(call[1]).toBe(""));
|
||||
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,34 +8,43 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { widget } = service;
|
||||
const isV2 = widget.version === 2;
|
||||
|
||||
const { data: usersData, error: usersError } = useWidgetAPI(widget, "users");
|
||||
|
||||
const loginsEndpoint = widget.version === 2 ? "loginv2" : "login";
|
||||
const loginsEndpoint = isV2 ? "" : "login";
|
||||
const { data: loginsData, error: loginsError } = useWidgetAPI(widget, loginsEndpoint);
|
||||
|
||||
const failedLoginsEndpoint = widget.version === 2 ? "login_failedv2" : "login_failed";
|
||||
const failedLoginsEndpoint = isV2 ? "" : "login_failed";
|
||||
const { data: failedLoginsData, error: failedLoginsError } = useWidgetAPI(widget, failedLoginsEndpoint);
|
||||
|
||||
if (usersError || loginsError || failedLoginsError) {
|
||||
const finalError = usersError ?? loginsError ?? failedLoginsError;
|
||||
const eventsDataEndpoint = isV2 ? "datav2" : "";
|
||||
const { data: eventsData, error: eventsDataError } = useWidgetAPI(widget, eventsDataEndpoint);
|
||||
|
||||
if (usersError || loginsError || failedLoginsError || eventsDataError) {
|
||||
const finalError = usersError ?? loginsError ?? failedLoginsError ?? eventsDataError;
|
||||
return <Container service={service} error={finalError} />;
|
||||
}
|
||||
|
||||
if (!usersData || !loginsData || !failedLoginsData) {
|
||||
const hasNoData = isV2 ? !usersData || !eventsData : !usersData || !loginsData || !failedLoginsData;
|
||||
|
||||
if (hasNoData) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="authentik.users" />
|
||||
<Block label="authentik.loginsLast24H" />
|
||||
<Block label="authentik.failedLoginsLast24H" />
|
||||
{isV2 && <Block label="authentik.authorizationsLast24H" />}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
let loginsLast24H;
|
||||
let failedLoginsLast24H;
|
||||
let authorizationsLast24H;
|
||||
switch (widget.version) {
|
||||
case 1:
|
||||
// v1 is default
|
||||
default:
|
||||
const yesterday = new Date(Date.now()).setHours(-24);
|
||||
loginsLast24H = loginsData.reduce(
|
||||
(total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),
|
||||
@@ -47,13 +56,28 @@ export default function Component({ service }) {
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
loginsLast24H =
|
||||
loginsData.reduce?.(
|
||||
(total, current) => (current?.count && current?.action === "login" ? total + current.count : total),
|
||||
0,
|
||||
) || 0;
|
||||
failedLoginsLast24H =
|
||||
failedLoginsData.reduce?.((total, current) => (current?.count ? total + current.count : total), 0) || 0;
|
||||
const events = Array.isArray(eventsData) ? eventsData : [];
|
||||
const result = events.reduce(
|
||||
(acc, current) => {
|
||||
if (!current?.count || !current?.action) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (current.action === "login") {
|
||||
acc.logins += current.count;
|
||||
} else if (current.action === "login_failed") {
|
||||
acc.failed += current.count;
|
||||
} else if (current.action === "authorize_application") {
|
||||
acc.authorizations += current.count;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ logins: 0, failed: 0, authorizations: 0 },
|
||||
);
|
||||
loginsLast24H = result.logins;
|
||||
failedLoginsLast24H = result.failed;
|
||||
authorizationsLast24H = result.authorizations;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -62,6 +86,9 @@ export default function Component({ service }) {
|
||||
<Block label="authentik.users" value={t("common.number", { value: usersData.pagination.count })} />
|
||||
<Block label="authentik.loginsLast24H" value={t("common.number", { value: loginsLast24H })} />
|
||||
<Block label="authentik.failedLoginsLast24H" value={t("common.number", { value: failedLoginsLast24H })} />
|
||||
{isV2 && (
|
||||
<Block label="authentik.authorizationsLast24H" value={t("common.number", { value: authorizationsLast24H })} />
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,24 +30,31 @@ describe("widgets/authentik/component", () => {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expect(screen.getByText("authentik.users")).toBeInTheDocument();
|
||||
expect(screen.getByText("authentik.loginsLast24H")).toBeInTheDocument();
|
||||
expect(screen.getByText("authentik.failedLoginsLast24H")).toBeInTheDocument();
|
||||
expect(screen.getByText("authentik.authorizationsLast24H")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("computes v2 login/failed counts from action data", () => {
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "users") return { data: { pagination: { count: 10 } }, error: undefined };
|
||||
if (endpoint === "loginv2")
|
||||
if (endpoint === "datav2")
|
||||
return {
|
||||
data: [
|
||||
{ action: "login", count: 2 },
|
||||
{ action: "login", count: 4 },
|
||||
{ action: "logout", count: 9 },
|
||||
{ action: "login_failed", count: 1 },
|
||||
{ action: "login_failed", count: 2 },
|
||||
{ action: "authorize_application", count: 10 },
|
||||
{ action: "authorize_application", count: 8 },
|
||||
{ action: null, count: null },
|
||||
{ action: "login", count: null },
|
||||
],
|
||||
error: undefined,
|
||||
};
|
||||
if (endpoint === "login_failedv2") return { data: [{ count: 3 }, { count: null }], error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
@@ -55,10 +62,11 @@ describe("widgets/authentik/component", () => {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
|
||||
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||
expectBlockValue(container, "authentik.users", 10);
|
||||
expectBlockValue(container, "authentik.loginsLast24H", 2);
|
||||
expectBlockValue(container, "authentik.loginsLast24H", 6);
|
||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 3);
|
||||
expectBlockValue(container, "authentik.authorizationsLast24H", 18);
|
||||
});
|
||||
|
||||
it("computes v1 login/failed counts for entries within the last 24h window", () => {
|
||||
@@ -99,4 +107,28 @@ describe("widgets/authentik/component", () => {
|
||||
expectBlockValue(container, "authentik.loginsLast24H", 2);
|
||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
|
||||
});
|
||||
|
||||
it("falls back to v1 when no version is configured", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
|
||||
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||
if (endpoint === "users") return { data: { pagination: { count: 5 } }, error: undefined };
|
||||
if (endpoint === "login") return { data: [{ x_cord: oneHourAgo, y_cord: 2 }], error: undefined };
|
||||
if (endpoint === "login_failed") return { data: [{ x_cord: oneHourAgo, y_cord: 1 }], error: undefined };
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
// service-helpers sets version to null when the key is absent
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "authentik", version: null } }} />,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expectBlockValue(container, "authentik.users", 5);
|
||||
expectBlockValue(container, "authentik.loginsLast24H", 2);
|
||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,14 +11,11 @@ const widget = {
|
||||
login: {
|
||||
endpoint: "events/events/per_month/?action=login",
|
||||
},
|
||||
loginv2: {
|
||||
endpoint: "events/events/volume/?action=login&&history_days=1",
|
||||
},
|
||||
login_failed: {
|
||||
endpoint: "events/events/per_month/?action=login_failed",
|
||||
},
|
||||
login_failedv2: {
|
||||
endpoint: "events/events/volume/?action=login_failed&&history_days=1",
|
||||
datav2: {
|
||||
endpoint: "events/events/volume/?actions=login&actions=login_failed&actions=authorize_application&history_days=1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,14 +28,14 @@ export function Day({ weekNumber, weekday, events, colorVariants, showDate, setS
|
||||
// selected same day style
|
||||
style +=
|
||||
displayDate.startOf("day").ts === showDate.startOf("day").ts
|
||||
? "text-black-500 bg-theme-100/20 dark:bg-white/10 rounded-md "
|
||||
? "text-black-500 bg-black/10 dark:bg-white/10 rounded-md "
|
||||
: "";
|
||||
|
||||
if (displayDate.startOf("day").ts === currentDate.startOf("day").ts) {
|
||||
// today style
|
||||
style += "text-black-500 bg-theme-100/20 dark:bg-black/20 rounded-md ";
|
||||
style += "text-black-500 bg-black/20! dark:bg-black/20! rounded-md ";
|
||||
} else {
|
||||
style += "hover:bg-theme-100/20 dark:hover:bg-white/5 rounded-md cursor-pointer";
|
||||
style += "calendar-day-hover rounded-md cursor-pointer";
|
||||
}
|
||||
|
||||
return style;
|
||||
@@ -110,6 +110,14 @@ export default function Monthly({ service, colorVariants, events, showDate, setS
|
||||
|
||||
return (
|
||||
<div className="w-full text-center">
|
||||
<style jsx>{`
|
||||
:global(.calendar-day-hover:hover) {
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
}
|
||||
:global(.dark:not(.theme-white) .calendar-day-hover:hover) {
|
||||
background-color: rgb(255 255 255 / 5%);
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex-col">
|
||||
<span>
|
||||
<button
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function Component({ service }) {
|
||||
}
|
||||
|
||||
// uptime info
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [hour, minutes, seconds] = infoData.data.up_time.split(":");
|
||||
const days = Math.floor(hour / 24);
|
||||
const uptime = `${t("common.number", { value: days })} ${t("diskstation.days")}`;
|
||||
|
||||
@@ -70,7 +70,7 @@ async function login(widget) {
|
||||
return body.AccessToken;
|
||||
}
|
||||
|
||||
async function apiGet(widget, endpoint, accessToken) {
|
||||
async function apiGet(widget, endpoint, accessToken, { allow404 = false } = {}) {
|
||||
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
|
||||
const [status, , data] = await httpProxy(url, {
|
||||
method: "GET",
|
||||
@@ -79,6 +79,11 @@ async function apiGet(widget, endpoint, accessToken) {
|
||||
},
|
||||
});
|
||||
|
||||
// e.g. progressstate 404s until a job has run
|
||||
if (status === 404 && allow404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error(`Duplicati request failed for ${endpoint}`);
|
||||
}
|
||||
@@ -108,7 +113,7 @@ export default async function duplicatiProxyHandler(req, res) {
|
||||
apiGet(widget, "backups", accessToken),
|
||||
apiGet(widget, "serverstate", accessToken),
|
||||
apiGet(widget, "notifications", accessToken),
|
||||
apiGet(widget, "progressstate", accessToken),
|
||||
apiGet(widget, "progressstate", accessToken, { allow404: true }),
|
||||
]);
|
||||
|
||||
const summary = buildSummary(
|
||||
|
||||
@@ -75,6 +75,38 @@ describe("widgets/duplicati/proxy", () => {
|
||||
expect(res.body.lastBackup).toBe("2026-07-12T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("tolerates progressstate 404 when no job has run", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "duplicati", url: "http://dup", password: "secret" });
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ AccessToken: "token" }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([]))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ ActiveTask: null }))])
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([]))])
|
||||
.mockResolvedValueOnce([
|
||||
404,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ Error: "No active backup", Code: 404 })),
|
||||
]);
|
||||
|
||||
const res = createMockRes();
|
||||
await duplicatiProxyHandler({ query: { group: "g", service: "s" } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.running).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 500 when a non-progressstate endpoint 404s", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "duplicati", url: "http://dup", password: "secret" });
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ AccessToken: "token" }))])
|
||||
.mockResolvedValue([404, "application/json", Buffer.from("{}")]);
|
||||
|
||||
const res = createMockRes();
|
||||
await duplicatiProxyHandler({ query: { group: "g", service: "s" } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 500 when login fails", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "duplicati", url: "http://dup", password: "secret" });
|
||||
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from("{}")]);
|
||||
|
||||
@@ -28,7 +28,6 @@ export default function Component({ service }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !data.error) {
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const gpuData = data.find((item) => item[item.key] == gpuName);
|
||||
|
||||
if (gpuData) {
|
||||
@@ -56,7 +55,6 @@ export default function Component({ service }) {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const gpuData = data.find((item) => item[item.key] == gpuName);
|
||||
|
||||
if (!gpuData) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Component({ service }) {
|
||||
|
||||
// Some APIs return raw arrays, others wrap the payload (e.g. { response: [...] }).
|
||||
const collections = collectionsStatsData?.response ?? collectionsStatsData;
|
||||
const tags = tagsStatsData?.response ?? tagsStatsData;
|
||||
const tags = tagsStatsData?.response ?? tagsStatsData?.data?.tags ?? tagsStatsData;
|
||||
|
||||
const totalLinks = Array.isArray(collections)
|
||||
? collections.reduce((sum, collection) => sum + (collection._count?.links || 0), 0)
|
||||
|
||||
@@ -67,4 +67,26 @@ describe("widgets/linkwarden/component", () => {
|
||||
expectBlockValue(container, "linkwarden.collections", 2);
|
||||
expectBlockValue(container, "linkwarden.tags", 4);
|
||||
});
|
||||
|
||||
it("computes the tags total from the nested Linkwarden API response", () => {
|
||||
useWidgetAPI.mockImplementation((_widget, endpoint) => {
|
||||
if (endpoint === "collections") {
|
||||
return { data: { response: [{ _count: { links: 2 } }] }, error: undefined };
|
||||
}
|
||||
|
||||
if (endpoint === "tags") {
|
||||
return { data: { data: { tags: [{ id: 1 }, { id: 2 }, { id: 3 }] } }, error: undefined };
|
||||
}
|
||||
|
||||
return { data: undefined, error: undefined };
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "linkwarden" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expectBlockValue(container, "linkwarden.links", 2);
|
||||
expectBlockValue(container, "linkwarden.collections", 1);
|
||||
expectBlockValue(container, "linkwarden.tags", 3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,8 @@ function formatResourceCount(total, active) {
|
||||
export default function Component({ service }) {
|
||||
const { widget } = service;
|
||||
|
||||
const { data: resourcesData, error: resourcesError } = useWidgetAPI(widget, "resources");
|
||||
const version = widget.version ?? 1;
|
||||
const { data: resourcesData, error: resourcesError } = useWidgetAPI(widget, version === 2 ? "summary" : "resources");
|
||||
|
||||
if (resourcesError) {
|
||||
return <Container service={service} error={resourcesError} />;
|
||||
@@ -55,6 +56,17 @@ export default function Component({ service }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (version === 2) {
|
||||
// pulse v6 (v2 widget) returns the counts directly in the summary
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="pulse.nodes" value={resourcesData.nodes} />
|
||||
<Block label="pulse.vms" value={resourcesData.vms} />
|
||||
<Block label="pulse.lxcs" value={resourcesData.containers} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
let resources = resourcesData.resources;
|
||||
if (!resources && resourcesData.count === 0) {
|
||||
resources = [];
|
||||
|
||||
@@ -27,6 +27,27 @@ describe("widgets/pulse/component", () => {
|
||||
expect(screen.getByText("pulse.nodes")).toBeInTheDocument();
|
||||
expect(screen.getByText("pulse.vms")).toBeInTheDocument();
|
||||
expect(screen.getByText("pulse.lxcs")).toBeInTheDocument();
|
||||
expect(useWidgetAPI).toHaveBeenCalledWith({ type: "pulse" }, "resources");
|
||||
});
|
||||
|
||||
it("renders Pulse v6 summary counts", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
nodes: 2,
|
||||
vms: 8,
|
||||
containers: 12,
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "pulse", version: 2 } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(useWidgetAPI).toHaveBeenCalledWith({ type: "pulse", version: 2 }, "summary");
|
||||
expectBlockValue(container, "pulse.nodes", 2);
|
||||
expectBlockValue(container, "pulse.vms", 8);
|
||||
expectBlockValue(container, "pulse.lxcs", 12);
|
||||
});
|
||||
|
||||
it("renders error UI when the resources endpoint errors", () => {
|
||||
|
||||
@@ -8,6 +8,9 @@ const widget = {
|
||||
resources: {
|
||||
endpoint: "api/resources",
|
||||
},
|
||||
summary: {
|
||||
endpoint: "api/state/summary",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
@@ -8,4 +8,8 @@ describe("pulse widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
|
||||
it("maps the Pulse v6 summary endpoint", () => {
|
||||
expect(widget.mappings.summary.endpoint).toBe("api/state/summary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Block from "components/services/widget/block";
|
||||
import Container from "components/services/widget/container";
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
export default function Component({ service }) {
|
||||
|
||||
@@ -8,7 +8,6 @@ export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
const { widget } = service;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { data: spoolData, error: spoolError } = useWidgetAPI(widget, "spools");
|
||||
|
||||
if (spoolError) {
|
||||
@@ -20,7 +19,6 @@ export default function Component({ service }) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
{[...Array(nBlocksGuess)].map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Block key={i} label="spoolman.loading" />
|
||||
))}
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable camelcase */
|
||||
import Container from "components/services/widget/container";
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
import { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill } from "react-icons/bs";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable camelcase */
|
||||
import Block from "components/services/widget/block";
|
||||
import Container from "components/services/widget/container";
|
||||
import { useTranslation } from "next-i18next/pages";
|
||||
|
||||
@@ -38,8 +38,8 @@ export default function Component({ service }) {
|
||||
const lan = defaultSite.health.find((h) => h.subsystem === "lan");
|
||||
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
|
||||
[wan, lan, wlan].forEach((s) => {
|
||||
s.up = s.status === "ok"; // eslint-disable-line no-param-reassign
|
||||
s.show = s.status !== "unknown"; // eslint-disable-line no-param-reassign
|
||||
s.up = s.status === "ok";
|
||||
s.show = s.status !== "unknown";
|
||||
});
|
||||
|
||||
const uptime = wan["gw_system-stats"]
|
||||
|
||||
@@ -11,7 +11,6 @@ afterEach(() => {
|
||||
// Avoid NextAuth client-side fetches during unit tests.
|
||||
vi.mock("next-auth/react", () => ({
|
||||
SessionProvider: ({ children }) => children ?? null,
|
||||
getProviders: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
// implement a couple of common formatters mocked in next-i18next
|
||||
|
||||
Reference in New Issue
Block a user