mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-12 15:15:58 -07:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21bf7ed8e2 | |||
| 728c6fbe18 | |||
| 6a96289717 | |||
| fa47a2d86c | |||
| cc22d04429 | |||
| a414f376b9 | |||
| bdd77342d8 | |||
| 63d2172ef7 | |||
| 85e4e439bc | |||
| 36aa241689 | |||
| 0933ba015a | |||
| c57117daae | |||
| 93763b7c10 | |||
| 6c551ace34 | |||
| 7886742d56 | |||
| a253861061 | |||
| edfb28ab5d | |||
| 3ab6b040b2 | |||
| 9cabb46bb7 | |||
| d7cc2d3f19 | |||
| b5084ba8b3 | |||
| dca0c9ab81 | |||
| d4fd14b724 | |||
| 600c9eee78 | |||
| 198c86fb49 | |||
| f711d290a7 | |||
| 342afa2215 | |||
| 27704f962e |
@@ -25,7 +25,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- name: Install uv
|
- 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
|
- run: sudo apt-get install pngquant
|
||||||
- name: Test Docs Build
|
- name: Test Docs Build
|
||||||
run: uv run --frozen zensical build --clean
|
run: uv run --frozen zensical build --clean
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- name: Install uv
|
- 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
|
- run: sudo apt-get install pngquant
|
||||||
- name: Build Docs
|
- name: Build Docs
|
||||||
run: uv run --frozen zensical build --clean
|
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.
|
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.
|
You can also set a specific `id` for a service or bookmark to target with your custom css or javascript, e.g.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ environment:
|
|||||||
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
|
HOMEPAGE_MCP_TOKEN: "generate-with-openssl-rand-base64-32"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Connecting Claude Desktop
|
||||||
|
|
||||||
|
Claude Desktop can use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to communicate with the homepage MCP endpoint in `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"homepage": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "mcp-remote", "http://localhost:3000/api/mcp", "--header", "X-Homepage-MCP-Token: your-token"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The **Add custom connector** option will not work: remote connectors authenticate with OAuth and cannot supply `HOMEPAGE_MCP_TOKEN`.
|
||||||
|
|
||||||
## Read-only by default
|
## Read-only by default
|
||||||
|
|
||||||
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
|
The MCP endpoint exposes tools for reading and validating supported Homepage config files. File writes are disabled unless you opt in with:
|
||||||
|
|||||||
@@ -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.
|
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`.
|
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).
|
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`.
|
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".
|
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 Core -> Can view User (Model: User)
|
||||||
- authentik Events -> Can view Event (Model: Event)
|
- 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 |
|
| Authentik Version | Homepage Widget Version |
|
||||||
| ----------------- | ----------------------- |
|
| ----------------- | ----------------------- |
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ widget:
|
|||||||
type: pulse
|
type: pulse
|
||||||
url: http://pulse.host.or.ip:7655
|
url: http://pulse.host.or.ip:7655
|
||||||
key: your-api-token # `monitoring:read` scope is required
|
key: your-api-token # `monitoring:read` scope is required
|
||||||
|
version: 2 # required for Pulse v6, defaults to 1
|
||||||
fields: ["nodes", "vms", "lxcs"] # optional
|
fields: ["nodes", "vms", "lxcs"] # optional
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const compat = new FlatCompat({
|
|||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,jsx}"],
|
||||||
|
|
||||||
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
|
extends: fixupConfigRules(compat.extends("next/core-web-vitals", "prettier", "plugin:react-hooks/recommended")),
|
||||||
|
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -63,6 +65,24 @@ export default defineConfig([
|
|||||||
allowElseIf: true,
|
allowElseIf: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Keep the pre-eslint-plugin-react-hooks v6 lint policy until rules are adopted incrementally
|
||||||
|
"react-hooks/config": "off",
|
||||||
|
"react-hooks/error-boundaries": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"react-hooks/gating": "off",
|
||||||
|
"react-hooks/globals": "off",
|
||||||
|
"react-hooks/immutability": "off",
|
||||||
|
"react-hooks/incompatible-library": "off",
|
||||||
|
"react-hooks/preserve-manual-memoization": "off",
|
||||||
|
"react-hooks/purity": "off",
|
||||||
|
"react-hooks/refs": "off",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"react-hooks/set-state-in-render": "off",
|
||||||
|
"react-hooks/static-components": "off",
|
||||||
|
"react-hooks/unsupported-syntax": "off",
|
||||||
|
"react-hooks/use-memo": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
|
// Vitest tests often intentionally place imports after `vi.mock(...)` to ensure
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "2.0.0",
|
"version": "2.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.10",
|
"@headlessui/react": "^2.2.10",
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^2.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"dockerode": "^5.0.0",
|
"dockerode": "^5.0.0",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"gamedig": "^5.3.3",
|
"gamedig": "^5.3.3",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"ical.js": "^2.2.1",
|
"ical.js": "^2.2.1",
|
||||||
"js-yaml": "^4.3.1",
|
"js-yaml": "^5.3.0",
|
||||||
"json-rpc-2.0": "^1.7.1",
|
"json-rpc-2.0": "^1.7.1",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
"swr": "^2.4.2",
|
"swr": "^2.5.1",
|
||||||
"systeminformation": "^5.33.1",
|
"systeminformation": "^5.33.1",
|
||||||
"tough-cookie": "^6.0.2",
|
"tough-cookie": "^6.0.2",
|
||||||
"urbackup-server-api": "^0.92.2",
|
"urbackup-server-api": "^0.92.2",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"eslint": "^9.25.1",
|
"eslint": "^9.25.1",
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
"prettier": "^3.8.4",
|
"prettier": "^3.8.4",
|
||||||
|
|||||||
Generated
+504
-294
File diff suppressed because it is too large
Load Diff
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Gebruikers",
|
"users": "Gebruikers",
|
||||||
"loginsLast24H": "Aantekenings (24h)",
|
"loginsLast24H": "Aantekenings (24h)",
|
||||||
"failedLoginsLast24H": "Mislukte Aantekenings (24h)"
|
"failedLoginsLast24H": "Mislukte Aantekenings (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "GEH",
|
"mem": "GEH",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "المستخدمون",
|
"users": "المستخدمون",
|
||||||
"loginsLast24H": "تسجيلات الدخول (٢٤س)",
|
"loginsLast24H": "تسجيلات الدخول (٢٤س)",
|
||||||
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)"
|
"failedLoginsLast24H": "فشل تسجيلات الدخول (٢٤س)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "الذاكرة",
|
"mem": "الذاكرة",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Потребители",
|
"users": "Потребители",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Usuaris",
|
"users": "Usuaris",
|
||||||
"loginsLast24H": "Inicis de sessió (24h)",
|
"loginsLast24H": "Inicis de sessió (24h)",
|
||||||
"failedLoginsLast24H": "Errors d'inici de sessió (24h)"
|
"failedLoginsLast24H": "Errors d'inici de sessió (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"seconds": "s"
|
"seconds": "s"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signout": "Sign out"
|
"signout": "Odhlásit se"
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"missing_type": "Chybí typ widgetu: {{type}}",
|
"missing_type": "Chybí typ widgetu: {{type}}",
|
||||||
@@ -75,13 +75,13 @@
|
|||||||
"no_data": "Nejsou k dispozici žádná data úložiště"
|
"no_data": "Nejsou k dispozici žádná data úložiště"
|
||||||
},
|
},
|
||||||
"duplicati": {
|
"duplicati": {
|
||||||
"jobs": "Jobs",
|
"jobs": "Úlohy",
|
||||||
"stored": "Stored",
|
"stored": "Uloženo",
|
||||||
"lastBackup": "Last Run",
|
"lastBackup": "Naposledy spuštěno",
|
||||||
"nextRun": "Next Run",
|
"nextRun": "Další spuštění",
|
||||||
"running": "Running",
|
"running": "Běží",
|
||||||
"warnings": "Warnings",
|
"warnings": "Upozornění",
|
||||||
"errors": "Errors"
|
"errors": "Chyby"
|
||||||
},
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"series": "Seriály",
|
"series": "Seriály",
|
||||||
"episodes": "Epizody",
|
"episodes": "Epizody",
|
||||||
"songs": "Skladby",
|
"songs": "Skladby",
|
||||||
"albums": "Albums"
|
"albums": "Alba"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Uživatelé",
|
"users": "Uživatelé",
|
||||||
"loginsLast24H": "Příhlášení (24h)",
|
"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": {
|
"proxmox": {
|
||||||
"mem": "Využití paměti",
|
"mem": "Využití paměti",
|
||||||
@@ -951,15 +952,15 @@
|
|||||||
"storage": "Úložiště"
|
"storage": "Úložiště"
|
||||||
},
|
},
|
||||||
"maintainerr": {
|
"maintainerr": {
|
||||||
"itemsHandled": "Items Handled",
|
"itemsHandled": "Zpracované položky",
|
||||||
"episodesHandled": "Episodes Handled",
|
"episodesHandled": "Zpracované epizody",
|
||||||
"moviesHandled": "Movies Handled",
|
"moviesHandled": "Zpracované filmy",
|
||||||
"showsHandled": "Shows Handled",
|
"showsHandled": "Zpracované seriály",
|
||||||
"seasonsHandled": "Seasons Handled",
|
"seasonsHandled": "Zpracované série",
|
||||||
"reclaimable": "Reclaimable",
|
"reclaimable": "Uvolnitelné",
|
||||||
"movieReclaimable": "Movie Reclaimable",
|
"movieReclaimable": "Uvolnitelné filmy",
|
||||||
"showReclaimable": "Show Reclaimable",
|
"showReclaimable": "Uvolnitelné seriály",
|
||||||
"totalCapacity": "Total Capacity"
|
"totalCapacity": "Celková kapacita"
|
||||||
},
|
},
|
||||||
"netdata": {
|
"netdata": {
|
||||||
"warnings": "Upozornění",
|
"warnings": "Upozornění",
|
||||||
@@ -1241,14 +1242,14 @@
|
|||||||
"steps": "Kroky"
|
"steps": "Kroky"
|
||||||
},
|
},
|
||||||
"syncthing": {
|
"syncthing": {
|
||||||
"connected": "Connected",
|
"connected": "Připojeno",
|
||||||
"synced": "Synced",
|
"synced": "Synchronizováno",
|
||||||
"errors": "Errors",
|
"errors": "Chyby",
|
||||||
"storage": "Storage"
|
"storage": "Úložiště"
|
||||||
},
|
},
|
||||||
"sportarr": {
|
"sportarr": {
|
||||||
"wanted": "Wanted",
|
"wanted": "Požadované",
|
||||||
"queued": "Queued",
|
"queued": "Ve frontě",
|
||||||
"leagues": "Leagues"
|
"leagues": "Ligy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Login (24 timer)",
|
"loginsLast24H": "Login (24 timer)",
|
||||||
"failedLoginsLast24H": "Mislykkede logins (24 timer)"
|
"failedLoginsLast24H": "Mislykkede logins (24 timer)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"loginsLast24H": "Anmeldungen (24 h)",
|
"loginsLast24H": "Anmeldungen (24 h)",
|
||||||
"failedLoginsLast24H": "Fehlversuche (24 h)"
|
"failedLoginsLast24H": "Fehlversuche (24 h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "RAM",
|
"mem": "RAM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Συνδέσεις (24h)",
|
"loginsLast24H": "Συνδέσεις (24h)",
|
||||||
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)"
|
"failedLoginsLast24H": "Αποτυχημένες Συνδέσεις (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
"series": "Series",
|
"series": "Series",
|
||||||
"episodes": "Episodios",
|
"episodes": "Episodios",
|
||||||
"songs": "Canciones",
|
"songs": "Canciones",
|
||||||
"albums": "Albums"
|
"albums": "Álbumes"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Fuera de línea",
|
"offline": "Fuera de línea",
|
||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Usuarios",
|
"users": "Usuarios",
|
||||||
"loginsLast24H": "Inicios de sesión (24h)",
|
"loginsLast24H": "Inicios de sesión (24h)",
|
||||||
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)"
|
"failedLoginsLast24H": "Inicios de sesión fallidos (24h)",
|
||||||
|
"authorizationsLast24H": "Autenticaciones (24 h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Kirjautumisia (24h)",
|
"loginsLast24H": "Kirjautumisia (24h)",
|
||||||
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)"
|
"failedLoginsLast24H": "Epäonnistuneita kirjautumisia (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"seconds": "s"
|
"seconds": "s"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signout": "Sign out"
|
"signout": "Se déconnecter"
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"missing_type": "Type de widget manquant : {{type}}",
|
"missing_type": "Type de widget manquant : {{type}}",
|
||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"loginsLast24H": "Connexions (24 h)",
|
"loginsLast24H": "Connexions (24 h)",
|
||||||
"failedLoginsLast24H": "Connexions échouées (24 h)"
|
"failedLoginsLast24H": "Connexions échouées (24 h)",
|
||||||
|
"authorizationsLast24H": "Authentifications (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "RAM",
|
"mem": "RAM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "משתמשים",
|
"users": "משתמשים",
|
||||||
"loginsLast24H": "כניסות (24h)",
|
"loginsLast24H": "כניסות (24h)",
|
||||||
"failedLoginsLast24H": "כניסות שנכשלו (24h)"
|
"failedLoginsLast24H": "כניסות שנכשלו (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "זיכרון",
|
"mem": "זיכרון",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Korisnici",
|
"users": "Korisnici",
|
||||||
"loginsLast24H": "Prijave (24 h)",
|
"loginsLast24H": "Prijave (24 h)",
|
||||||
"failedLoginsLast24H": "Neuspjele prijave (24 h)"
|
"failedLoginsLast24H": "Neuspjele prijave (24 h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Felhasználók",
|
"users": "Felhasználók",
|
||||||
"loginsLast24H": "Bejelentkezések (24 óra)",
|
"loginsLast24H": "Bejelentkezések (24 óra)",
|
||||||
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)"
|
"failedLoginsLast24H": "Sikertelen bejelentkezések (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Login (24j)",
|
"loginsLast24H": "Login (24j)",
|
||||||
"failedLoginsLast24H": "Login Gagal (24j)"
|
"failedLoginsLast24H": "Login Gagal (24j)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Utenti",
|
"users": "Utenti",
|
||||||
"loginsLast24H": "Accessi (24h)",
|
"loginsLast24H": "Accessi (24h)",
|
||||||
"failedLoginsLast24H": "Accessi Falliti (24h)"
|
"failedLoginsLast24H": "Accessi Falliti (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "ログイン (24時間)",
|
"loginsLast24H": "ログイン (24時間)",
|
||||||
"failedLoginsLast24H": "ログイン失敗(24時間)"
|
"failedLoginsLast24H": "ログイン失敗(24時間)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "사용자",
|
"users": "사용자",
|
||||||
"loginsLast24H": "로그인 (24시간)",
|
"loginsLast24H": "로그인 (24시간)",
|
||||||
"failedLoginsLast24H": "실패한 로그인 (24시간)"
|
"failedLoginsLast24H": "실패한 로그인 (24시간)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "메모리",
|
"mem": "메모리",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logmasuk (24j)",
|
"loginsLast24H": "Logmasuk (24j)",
|
||||||
"failedLoginsLast24H": "Logmasuk Gagal (24j)"
|
"failedLoginsLast24H": "Logmasuk Gagal (24j)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Gebruikers",
|
"users": "Gebruikers",
|
||||||
"loginsLast24H": "Logins (24u)",
|
"loginsLast24H": "Logins (24u)",
|
||||||
"failedLoginsLast24H": "Mislukte Logins (24u)"
|
"failedLoginsLast24H": "Mislukte Logins (24u)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "GEH",
|
"mem": "GEH",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Mislykket innlogginger (24t)"
|
"failedLoginsLast24H": "Mislykket innlogginger (24t)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"seconds": "s"
|
"seconds": "s"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signout": "Sign out"
|
"signout": "Wyloguj się"
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"missing_type": "Brakujący typ widżetu: {{type}}",
|
"missing_type": "Brakujący typ widżetu: {{type}}",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"series": "Seriale",
|
"series": "Seriale",
|
||||||
"episodes": "Odcinki",
|
"episodes": "Odcinki",
|
||||||
"songs": "Piosenki",
|
"songs": "Piosenki",
|
||||||
"albums": "Albums"
|
"albums": "Albumy"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Użytkownicy",
|
"users": "Użytkownicy",
|
||||||
"loginsLast24H": "Logowania (24h)",
|
"loginsLast24H": "Logowania (24h)",
|
||||||
"failedLoginsLast24H": "Nieudane logowania (24h)"
|
"failedLoginsLast24H": "Nieudane logowania (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "RAM",
|
"mem": "RAM",
|
||||||
|
|||||||
+428
-427
File diff suppressed because it is too large
Load Diff
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Usuários",
|
"users": "Usuários",
|
||||||
"loginsLast24H": "Inícios de sessão (24h)",
|
"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": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Autentificări (24h)",
|
"loginsLast24H": "Autentificări (24h)",
|
||||||
"failedLoginsLast24H": "Conectări eșuate (24h)"
|
"failedLoginsLast24H": "Conectări eșuate (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"seconds": "сек"
|
"seconds": "сек"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signout": "Sign out"
|
"signout": "Выход"
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"missing_type": "Отсутствует тип виджета: {{type}}",
|
"missing_type": "Отсутствует тип виджета: {{type}}",
|
||||||
@@ -75,13 +75,13 @@
|
|||||||
"no_data": "Нет доступных данных о хранилище"
|
"no_data": "Нет доступных данных о хранилище"
|
||||||
},
|
},
|
||||||
"duplicati": {
|
"duplicati": {
|
||||||
"jobs": "Jobs",
|
"jobs": "Работы",
|
||||||
"stored": "Stored",
|
"stored": "Сохранено",
|
||||||
"lastBackup": "Last Run",
|
"lastBackup": "Последний запуск",
|
||||||
"nextRun": "Next Run",
|
"nextRun": "Следующий запуск",
|
||||||
"running": "Running",
|
"running": "Запущено",
|
||||||
"warnings": "Warnings",
|
"warnings": "Предупреждения",
|
||||||
"errors": "Errors"
|
"errors": "Ошибки"
|
||||||
},
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"series": "Сериалы",
|
"series": "Сериалы",
|
||||||
"episodes": "Эпизоды",
|
"episodes": "Эпизоды",
|
||||||
"songs": "Песни",
|
"songs": "Песни",
|
||||||
"albums": "Albums"
|
"albums": "Альбомы"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Не в сети",
|
"offline": "Не в сети",
|
||||||
@@ -235,17 +235,17 @@
|
|||||||
"rutorrent": {
|
"rutorrent": {
|
||||||
"active": "Активно",
|
"active": "Активно",
|
||||||
"upload": "Отдача",
|
"upload": "Отдача",
|
||||||
"download": "Скачивание"
|
"download": "Загрузка"
|
||||||
},
|
},
|
||||||
"transmission": {
|
"transmission": {
|
||||||
"download": "Скачивание",
|
"download": "Загрузка",
|
||||||
"upload": "Отдача",
|
"upload": "Отдача",
|
||||||
"leech": "Лич",
|
"leech": "Лич",
|
||||||
"seed": "Сид"
|
"seed": "Сид"
|
||||||
},
|
},
|
||||||
"qbittorrent": {
|
"qbittorrent": {
|
||||||
"download": "Скачивание",
|
"download": "Загрузка",
|
||||||
"upload": "Загрузка",
|
"upload": "Отдача",
|
||||||
"leech": "Лич",
|
"leech": "Лич",
|
||||||
"seed": "Сид"
|
"seed": "Сид"
|
||||||
},
|
},
|
||||||
@@ -258,8 +258,8 @@
|
|||||||
"invalid": "Некорректный"
|
"invalid": "Некорректный"
|
||||||
},
|
},
|
||||||
"deluge": {
|
"deluge": {
|
||||||
"download": "Скачивание",
|
"download": "Загрузка",
|
||||||
"upload": "Загрузка",
|
"upload": "Отдача",
|
||||||
"leech": "Лич",
|
"leech": "Лич",
|
||||||
"seed": "Сид"
|
"seed": "Сид"
|
||||||
},
|
},
|
||||||
@@ -268,8 +268,8 @@
|
|||||||
"cachemissbytes": "Мисс байты кэша"
|
"cachemissbytes": "Мисс байты кэша"
|
||||||
},
|
},
|
||||||
"downloadstation": {
|
"downloadstation": {
|
||||||
"download": "Скачивание",
|
"download": "Загрузка",
|
||||||
"upload": "Загрузка",
|
"upload": "Отдача",
|
||||||
"leech": "Лич",
|
"leech": "Лич",
|
||||||
"seed": "Сид"
|
"seed": "Сид"
|
||||||
},
|
},
|
||||||
@@ -334,8 +334,8 @@
|
|||||||
"latency": "Задержка"
|
"latency": "Задержка"
|
||||||
},
|
},
|
||||||
"speedtest": {
|
"speedtest": {
|
||||||
"upload": "Загрузка",
|
"upload": "Отдача",
|
||||||
"download": "Скачивание",
|
"download": "Загрузка",
|
||||||
"ping": "Пинг"
|
"ping": "Пинг"
|
||||||
},
|
},
|
||||||
"portainer": {
|
"portainer": {
|
||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"loginsLast24H": "Входы (24ч)",
|
"loginsLast24H": "Входы (24ч)",
|
||||||
"failedLoginsLast24H": "Неудачные входы (24ч)"
|
"failedLoginsLast24H": "Неудачные входы (24ч)",
|
||||||
|
"authorizationsLast24H": "Входы (24ч)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "Память",
|
"mem": "Память",
|
||||||
@@ -480,9 +481,9 @@
|
|||||||
"vms": "Виртуальные машины"
|
"vms": "Виртуальные машины"
|
||||||
},
|
},
|
||||||
"pulse": {
|
"pulse": {
|
||||||
"nodes": "Nodes",
|
"nodes": "Узлы",
|
||||||
"vms": "VMs",
|
"vms": "Виртуальные машины",
|
||||||
"lxcs": "LXCs"
|
"lxcs": "Контейнеры LXC"
|
||||||
},
|
},
|
||||||
"glances": {
|
"glances": {
|
||||||
"cpu": "ЦП",
|
"cpu": "ЦП",
|
||||||
@@ -581,7 +582,7 @@
|
|||||||
"up": "В сети",
|
"up": "В сети",
|
||||||
"pending": "Ожидают",
|
"pending": "Ожидают",
|
||||||
"down": "Не в сети",
|
"down": "Не в сети",
|
||||||
"ok": "Ok"
|
"ok": "OK"
|
||||||
},
|
},
|
||||||
"healthchecks": {
|
"healthchecks": {
|
||||||
"new": "Новый",
|
"new": "Новый",
|
||||||
@@ -649,7 +650,7 @@
|
|||||||
"total": "Всего"
|
"total": "Всего"
|
||||||
},
|
},
|
||||||
"pangolin": {
|
"pangolin": {
|
||||||
"orgs": "Orgs",
|
"orgs": "Организации",
|
||||||
"sites": "Сайты",
|
"sites": "Сайты",
|
||||||
"resources": "Ресурсы",
|
"resources": "Ресурсы",
|
||||||
"targets": "Цели",
|
"targets": "Цели",
|
||||||
@@ -687,7 +688,7 @@
|
|||||||
"limit": "Лимит"
|
"limit": "Лимит"
|
||||||
},
|
},
|
||||||
"opnsense": {
|
"opnsense": {
|
||||||
"cpu": "ЦП",
|
"cpu": "Нагрузка ЦП",
|
||||||
"memory": "Активно ОЗУ",
|
"memory": "Активно ОЗУ",
|
||||||
"wanUpload": "WAN Загрузка",
|
"wanUpload": "WAN Загрузка",
|
||||||
"wanDownload": "WAN скачивание"
|
"wanDownload": "WAN скачивание"
|
||||||
@@ -822,7 +823,7 @@
|
|||||||
"gross_percent_today": "Сегодня",
|
"gross_percent_today": "Сегодня",
|
||||||
"gross_percent_1y": "Один год",
|
"gross_percent_1y": "Один год",
|
||||||
"gross_percent_max": "Все время",
|
"gross_percent_max": "Все время",
|
||||||
"net_worth": "Net Worth"
|
"net_worth": "Общая стоимость"
|
||||||
},
|
},
|
||||||
"audiobookshelf": {
|
"audiobookshelf": {
|
||||||
"podcasts": "Подкасты",
|
"podcasts": "Подкасты",
|
||||||
@@ -951,15 +952,15 @@
|
|||||||
"storage": "Хранилище"
|
"storage": "Хранилище"
|
||||||
},
|
},
|
||||||
"maintainerr": {
|
"maintainerr": {
|
||||||
"itemsHandled": "Items Handled",
|
"itemsHandled": "Элементы обрабатываются",
|
||||||
"episodesHandled": "Episodes Handled",
|
"episodesHandled": "Серии обрабатываются",
|
||||||
"moviesHandled": "Movies Handled",
|
"moviesHandled": "Фильмы обрабатываются",
|
||||||
"showsHandled": "Shows Handled",
|
"showsHandled": "Шоу обрабатываются",
|
||||||
"seasonsHandled": "Seasons Handled",
|
"seasonsHandled": "Сезоны обрабатываются",
|
||||||
"reclaimable": "Reclaimable",
|
"reclaimable": "Восстановленные",
|
||||||
"movieReclaimable": "Movie Reclaimable",
|
"movieReclaimable": "Восстановленный фильм",
|
||||||
"showReclaimable": "Show Reclaimable",
|
"showReclaimable": "Восстановленное шоу",
|
||||||
"totalCapacity": "Total Capacity"
|
"totalCapacity": "Общая ёмкость"
|
||||||
},
|
},
|
||||||
"netdata": {
|
"netdata": {
|
||||||
"warnings": "Предупреждения",
|
"warnings": "Предупреждения",
|
||||||
@@ -968,7 +969,7 @@
|
|||||||
"ntfy": {
|
"ntfy": {
|
||||||
"title": "Название",
|
"title": "Название",
|
||||||
"priority": "Приоритет",
|
"priority": "Приоритет",
|
||||||
"lastReceived": "Last Received",
|
"lastReceived": "Последние полученные",
|
||||||
"message": "Сообщение",
|
"message": "Сообщение",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
"none": "Отсутствует",
|
"none": "Отсутствует",
|
||||||
@@ -1105,7 +1106,7 @@
|
|||||||
"apps": "Приложения",
|
"apps": "Приложения",
|
||||||
"synced": "Синхронизированные",
|
"synced": "Синхронизированные",
|
||||||
"outOfSync": "Не синхронизированные",
|
"outOfSync": "Не синхронизированные",
|
||||||
"healthy": "Healthy",
|
"healthy": "Здоров",
|
||||||
"degraded": "Деградированные",
|
"degraded": "Деградированные",
|
||||||
"progressing": "Выполняются",
|
"progressing": "Выполняются",
|
||||||
"missing": "Отсутствует",
|
"missing": "Отсутствует",
|
||||||
@@ -1152,18 +1153,18 @@
|
|||||||
"other": "Другое"
|
"other": "Другое"
|
||||||
},
|
},
|
||||||
"checkmk": {
|
"checkmk": {
|
||||||
"serviceErrors": "Service issues",
|
"serviceErrors": "Проблемы сервиса",
|
||||||
"hostErrors": "Host issues"
|
"hostErrors": "Проблемы узла"
|
||||||
},
|
},
|
||||||
"komodo": {
|
"komodo": {
|
||||||
"total": "Всего",
|
"total": "Всего",
|
||||||
"running": "Запущено",
|
"running": "Запущено",
|
||||||
"stopped": "Остановлено",
|
"stopped": "Остановлено",
|
||||||
"down": "Не в сети",
|
"down": "Не в сети",
|
||||||
"unhealthy": "Unhealthy",
|
"unhealthy": "Неисправен",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
"servers": "Серверы",
|
"servers": "Серверы",
|
||||||
"stacks": "Stacks",
|
"stacks": "Стеки",
|
||||||
"containers": "Контейнеры"
|
"containers": "Контейнеры"
|
||||||
},
|
},
|
||||||
"filebrowser": {
|
"filebrowser": {
|
||||||
@@ -1186,7 +1187,7 @@
|
|||||||
"DISABLE_DISK": "Диск отключен",
|
"DISABLE_DISK": "Диск отключен",
|
||||||
"SWAP_DSBL": "Swap отключён",
|
"SWAP_DSBL": "Swap отключён",
|
||||||
"INVALID_EXPANSION": "Неверное Расширение",
|
"INVALID_EXPANSION": "Неверное Расширение",
|
||||||
"PARITY_NOT_BIGGEST": "Parity Not Biggest",
|
"PARITY_NOT_BIGGEST": "Четность не наибольшая",
|
||||||
"TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков",
|
"TOO_MANY_MISSING_DISKS": "Слишком много отсутствующих дисков",
|
||||||
"NEW_DISK_TOO_SMALL": "Новый диск слишком мал",
|
"NEW_DISK_TOO_SMALL": "Новый диск слишком мал",
|
||||||
"NO_DATA_DISKS": "Нет дисков данных",
|
"NO_DATA_DISKS": "Нет дисков данных",
|
||||||
@@ -1195,18 +1196,18 @@
|
|||||||
"cpu": "ЦП",
|
"cpu": "ЦП",
|
||||||
"memoryUsed": "Использовано ОЗУ",
|
"memoryUsed": "Использовано ОЗУ",
|
||||||
"memoryAvailable": "Доступная память",
|
"memoryAvailable": "Доступная память",
|
||||||
"arrayUsed": "Array Used",
|
"arrayUsed": "Используемый массив",
|
||||||
"arrayFree": "Array Free",
|
"arrayFree": "Свободный массив",
|
||||||
"poolUsed": "{{pool}} Использовано",
|
"poolUsed": "{{pool}} Использовано",
|
||||||
"poolFree": "{{pool}} Свободно"
|
"poolFree": "{{pool}} Свободно"
|
||||||
},
|
},
|
||||||
"backrest": {
|
"backrest": {
|
||||||
"num_plans": "Plans",
|
"num_plans": "Планы",
|
||||||
"num_success_30": "Successes",
|
"num_success_30": "Успешные",
|
||||||
"num_failure_30": "Ошибки",
|
"num_failure_30": "Ошибки",
|
||||||
"num_success_latest": "Succeeding",
|
"num_success_latest": "Успешно",
|
||||||
"num_failure_latest": "Failing",
|
"num_failure_latest": "Неудачные",
|
||||||
"bytes_added_30": "Bytes Added"
|
"bytes_added_30": "Байты добавлены"
|
||||||
},
|
},
|
||||||
"yourspotify": {
|
"yourspotify": {
|
||||||
"songs": "Треков",
|
"songs": "Треков",
|
||||||
@@ -1235,20 +1236,20 @@
|
|||||||
"environment_not_found": "Среда не найдена"
|
"environment_not_found": "Среда не найдена"
|
||||||
},
|
},
|
||||||
"sparkyfitness": {
|
"sparkyfitness": {
|
||||||
"eaten": "Eaten",
|
"eaten": "Съедено",
|
||||||
"burned": "Burned",
|
"burned": "Сожжено",
|
||||||
"remaining": "Remaining",
|
"remaining": "Осталось",
|
||||||
"steps": "Steps"
|
"steps": "Шаги"
|
||||||
},
|
},
|
||||||
"syncthing": {
|
"syncthing": {
|
||||||
"connected": "Connected",
|
"connected": "Подключено",
|
||||||
"synced": "Synced",
|
"synced": "Синхронизировано",
|
||||||
"errors": "Errors",
|
"errors": "Ошибки",
|
||||||
"storage": "Storage"
|
"storage": "Хранилище"
|
||||||
},
|
},
|
||||||
"sportarr": {
|
"sportarr": {
|
||||||
"wanted": "Wanted",
|
"wanted": "Требуется",
|
||||||
"queued": "Queued",
|
"queued": "В очереди",
|
||||||
"leagues": "Leagues"
|
"leagues": "Лиги"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"seconds": "s"
|
"seconds": "s"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signout": "Sign out"
|
"signout": "Odhlásiť sa"
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"missing_type": "Chýba typ widgetu: {{type}}",
|
"missing_type": "Chýba typ widgetu: {{type}}",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"series": "Seriálov",
|
"series": "Seriálov",
|
||||||
"episodes": "Epizód",
|
"episodes": "Epizód",
|
||||||
"songs": "Skladieb",
|
"songs": "Skladieb",
|
||||||
"albums": "Albums"
|
"albums": "Albumov"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Nedostupné",
|
"offline": "Nedostupné",
|
||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Používateľov",
|
"users": "Používateľov",
|
||||||
"loginsLast24H": "Prihlás. (24 hod.)",
|
"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": {
|
"proxmox": {
|
||||||
"mem": "RAM",
|
"mem": "RAM",
|
||||||
@@ -959,7 +960,7 @@
|
|||||||
"reclaimable": "Reclaimable",
|
"reclaimable": "Reclaimable",
|
||||||
"movieReclaimable": "Movie Reclaimable",
|
"movieReclaimable": "Movie Reclaimable",
|
||||||
"showReclaimable": "Show Reclaimable",
|
"showReclaimable": "Show Reclaimable",
|
||||||
"totalCapacity": "Total Capacity"
|
"totalCapacity": "Celková kapacita"
|
||||||
},
|
},
|
||||||
"netdata": {
|
"netdata": {
|
||||||
"warnings": "Upozornenia",
|
"warnings": "Upozornenia",
|
||||||
@@ -1248,7 +1249,7 @@
|
|||||||
},
|
},
|
||||||
"sportarr": {
|
"sportarr": {
|
||||||
"wanted": "Wanted",
|
"wanted": "Wanted",
|
||||||
"queued": "Queued",
|
"queued": "V poradí",
|
||||||
"leagues": "Leagues"
|
"leagues": "Leagues"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Prijave (24h)",
|
"loginsLast24H": "Prijave (24h)",
|
||||||
"failedLoginsLast24H": "Neveljavne prijave (24h)"
|
"failedLoginsLast24H": "Neveljavne prijave (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Корисника",
|
"users": "Корисника",
|
||||||
"loginsLast24H": "Пријаве (24ч)",
|
"loginsLast24H": "Пријаве (24ч)",
|
||||||
"failedLoginsLast24H": "Неуспешне пријаве (24ч)"
|
"failedLoginsLast24H": "Неуспешне пријаве (24ч)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "Меморија",
|
"mem": "Меморија",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Inloggningar (24h)",
|
"loginsLast24H": "Inloggningar (24h)",
|
||||||
"failedLoginsLast24H": "Misslyckade inloggningar (24h)"
|
"failedLoginsLast24H": "Misslyckade inloggningar (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "లాగిన్లు (24గం)",
|
"loginsLast24H": "లాగిన్లు (24గం)",
|
||||||
"failedLoginsLast24H": "విఫలమైన లాగిన్లు (24గం)"
|
"failedLoginsLast24H": "విఫలమైన లాగిన్లు (24గం)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Kullanıcılar",
|
"users": "Kullanıcılar",
|
||||||
"loginsLast24H": "Girişler (24 Saat)",
|
"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": {
|
"proxmox": {
|
||||||
"mem": "Bellek",
|
"mem": "Bellek",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Користувачі",
|
"users": "Користувачі",
|
||||||
"loginsLast24H": "Вхід (протягом доби)",
|
"loginsLast24H": "Вхід (протягом доби)",
|
||||||
"failedLoginsLast24H": "Невдалі входи (протягом доби)"
|
"failedLoginsLast24H": "Невдалі входи (протягом доби)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "Пам'ять",
|
"mem": "Пам'ять",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "Logins (24h)",
|
"loginsLast24H": "Logins (24h)",
|
||||||
"failedLoginsLast24H": "Failed Logins (24h)"
|
"failedLoginsLast24H": "Failed Logins (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "登錄( 24小时)",
|
"loginsLast24H": "登錄( 24小时)",
|
||||||
"failedLoginsLast24H": "登錄失敗( 24鐘頭)"
|
"failedLoginsLast24H": "登錄失敗( 24鐘頭)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "登录 (24h)",
|
"loginsLast24H": "登录 (24h)",
|
||||||
"failedLoginsLast24H": "登录失败 (24h)"
|
"failedLoginsLast24H": "登录失败 (24h)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "内存",
|
"mem": "内存",
|
||||||
|
|||||||
@@ -471,7 +471,8 @@
|
|||||||
"authentik": {
|
"authentik": {
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"loginsLast24H": "登入 (過去 24 小時)",
|
"loginsLast24H": "登入 (過去 24 小時)",
|
||||||
"failedLoginsLast24H": "登入失敗 (過去 24 小時)"
|
"failedLoginsLast24H": "登入失敗 (過去 24 小時)",
|
||||||
|
"authorizationsLast24H": "Auths (24h)"
|
||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
|
|||||||
@@ -3,21 +3,27 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { getSettingsMock } = vi.hoisted(() => ({
|
const { getSettingsMock, authOptionsMock } = vi.hoisted(() => ({
|
||||||
getSettingsMock: vi.fn(),
|
getSettingsMock: vi.fn(),
|
||||||
|
authOptionsMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
getSettings: getSettingsMock,
|
getSettings: getSettingsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("pages/api/auth/[...nextauth]", () => ({
|
||||||
|
get authOptions() {
|
||||||
|
return authOptionsMock();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("next/router", () => ({
|
vi.mock("next/router", () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
query: {},
|
query: {},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getProviders } from "next-auth/react";
|
|
||||||
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
||||||
|
|
||||||
describe("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(() => {
|
await waitFor(() => {
|
||||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
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 () => {
|
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({
|
getSettingsMock.mockReturnValueOnce({
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
@@ -79,11 +85,10 @@ describe("pages/auth/signin", () => {
|
|||||||
|
|
||||||
const res = await getServerSideProps({});
|
const res = await getServerSideProps({});
|
||||||
|
|
||||||
expect(getProviders).toHaveBeenCalled();
|
|
||||||
expect(getSettingsMock).toHaveBeenCalled();
|
expect(getSettingsMock).toHaveBeenCalled();
|
||||||
expect(res).toEqual({
|
expect(res).toEqual({
|
||||||
props: {
|
props: {
|
||||||
providers: { foo: { id: "foo", name: "Foo" } },
|
providers: { foo: { id: "foo", name: "Foo", type: "oauth" } },
|
||||||
settings: {
|
settings: {
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
@@ -96,4 +101,53 @@ describe("pages/auth/signin", () => {
|
|||||||
expect(res.props.settings).not.toHaveProperty("providers");
|
expect(res.props.settings).not.toHaveProperty("providers");
|
||||||
expect(res.props.settings).not.toHaveProperty("layout");
|
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
|
// You can also log error messages to an error reporting service here
|
||||||
if (error || errorInfo) {
|
if (error || errorInfo) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("component error: %s, info: %s", error, errorInfo);
|
console.error("component error: %s, info: %s", error, errorInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
|||||||
let descriptionMatch;
|
let descriptionMatch;
|
||||||
if (searchDescriptions) {
|
if (searchDescriptions) {
|
||||||
descriptionMatch = r.description?.toLowerCase().includes(searchString);
|
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;
|
return nameMatch || descriptionMatch;
|
||||||
});
|
});
|
||||||
@@ -244,7 +244,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea
|
|||||||
<span>
|
<span>
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
part.toLowerCase() === searchString.toLowerCase() ? (
|
part.toLowerCase() === searchString.toLowerCase() ? (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<span key={`${searchString}_${i}`} className="bg-theme-300/10">
|
<span key={`${searchString}_${i}`} className="bg-theme-300/10">
|
||||||
{part}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ export default function Error({ error }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
error = { message: error }; // eslint-disable-line no-param-reassign
|
error = { message: error };
|
||||||
} else if (typeof error === "number") {
|
} else if (typeof error === "number") {
|
||||||
error = { message: `Error ${error}` }; // eslint-disable-line no-param-reassign
|
error = { message: `Error ${error}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error?.data?.error) {
|
if (error?.data?.error) {
|
||||||
error = error.data.error; // eslint-disable-line no-param-reassign
|
error = error.data.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Resource from "../widget/resource";
|
|||||||
|
|
||||||
export default function Network({ options, refresh = 1500 }) {
|
export default function Network({ options, refresh = 1500 }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
if (options.network === true) options.network = "default";
|
if (options.network === true) options.network = "default";
|
||||||
|
|
||||||
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {
|
const { data, error } = useSWR(`/api/widgets/resources?type=network&interfaceName=${options.network}`, {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@headlessui/react";
|
} from "@headlessui/react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "next-i18next/pages";
|
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 { BiLogoBing } from "react-icons/bi";
|
||||||
import { FiSearch } from "react-icons/fi";
|
import { FiSearch } from "react-icons/fi";
|
||||||
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
|
import { SiBaidu, SiBrave, SiDuckduckgo, SiGoogle } from "react-icons/si";
|
||||||
@@ -57,12 +57,12 @@ export const searchProviders = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAvailableProviderIds(options) {
|
function getAvailableProviderIds(provider) {
|
||||||
if (options.provider && Array.isArray(options.provider)) {
|
if (provider && Array.isArray(provider)) {
|
||||||
return options.provider.filter((value) => searchProviders.hasOwnProperty(value));
|
return provider.filter((value) => searchProviders.hasOwnProperty(value));
|
||||||
}
|
}
|
||||||
if (options.provider && searchProviders[options.provider]) {
|
if (provider && searchProviders[provider]) {
|
||||||
return [options.provider];
|
return [provider];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,8 @@ export function getStoredProvider() {
|
|||||||
export default function Search({ options }) {
|
export default function Search({ options }) {
|
||||||
const { t } = useTranslation();
|
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 [query, setQuery] = useState("");
|
||||||
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
|
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
|
||||||
@@ -184,7 +185,6 @@ export default function Search({ options }) {
|
|||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
||||||
autoFocus={options.focus}
|
autoFocus={options.focus}
|
||||||
onBlur={(e) => e.preventDefault()}
|
onBlur={(e) => e.preventDefault()}
|
||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
|
|||||||
export default function Widget({ options }) {
|
export default function Widget({ options }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign, no-multi-assign
|
|
||||||
options.service_group = options.service_name = "unifi_console";
|
options.service_group = options.service_name = "unifi_console";
|
||||||
const { data: statsData, error: statsError } = useWidgetAPI(options, "stat/sites", { index: options.index });
|
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 lan = defaultSite.health.find((h) => h.subsystem === "lan");
|
||||||
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
|
const wlan = defaultSite.health.find((h) => h.subsystem === "wlan");
|
||||||
[wan, lan, wlan].forEach((s) => {
|
[wan, lan, wlan].forEach((s) => {
|
||||||
s.up = s.status === "ok"; // eslint-disable-line no-param-reassign
|
s.up = s.status === "ok";
|
||||||
s.show = s.status !== "unknown"; // eslint-disable-line no-param-reassign
|
s.show = s.status !== "unknown";
|
||||||
});
|
});
|
||||||
const name = wan.gw_name ?? defaultSite.desc;
|
const name = wan.gw_name ?? defaultSite.desc;
|
||||||
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
|
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 = "") {
|
export function getAllClasses(options, additionalClassNames = "") {
|
||||||
if (options?.style?.header === "boxedWidgets") {
|
if (options?.style?.header === "boxedWidgets") {
|
||||||
if (options?.style?.cardBlur !== undefined) {
|
if (options?.style?.cardBlur !== undefined) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
additionalClassNames = [
|
additionalClassNames = [
|
||||||
additionalClassNames,
|
additionalClassNames,
|
||||||
`backdrop-blur${options.style.cardBlur.length ? "-" : ""}${options.style.cardBlur}`,
|
`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;
|
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.
|
// The MCP API handler authorizes both bearer tokens and Homepage sessions.
|
||||||
if (pathname === "/api/mcp") {
|
if (pathname === "/api/mcp") {
|
||||||
return withPrivateCache(NextResponse.next());
|
return withPrivateCache(NextResponse.next());
|
||||||
|
|||||||
@@ -100,6 +100,31 @@ describe("middleware", () => {
|
|||||||
expect(res.type).toBe("next");
|
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) => {
|
it.each(["false", "0", "no", "off", ""])("treats HOMEPAGE_AUTH_ENABLED=%j as disabled", async (value) => {
|
||||||
process.env.HOMEPAGE_AUTH_ENABLED = 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 { SessionProvider } from "next-auth/react";
|
||||||
import { appWithTranslation } from "next-i18next/pages";
|
import { appWithTranslation } from "next-i18next/pages";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
@@ -13,7 +12,6 @@ import { ThemeProvider } from "utils/contexts/theme";
|
|||||||
|
|
||||||
import nextI18nextConfig from "../../next-i18next.config";
|
import nextI18nextConfig from "../../next-i18next.config";
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const tailwindSafelist = [
|
const tailwindSafelist = [
|
||||||
// TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147
|
// TODO: remove pending https://github.com/tailwindlabs/tailwindcss/pull/17147
|
||||||
"backdrop-blur",
|
"backdrop-blur",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
|
||||||
import { isAuthEnabled } from "utils/env";
|
import { applyNextAuthEnv, isAuthEnabled } from "utils/env";
|
||||||
import createLogger from "utils/logger";
|
import createLogger from "utils/logger";
|
||||||
|
|
||||||
const MIN_AUTH_SECRET_LENGTH = 32;
|
const MIN_AUTH_SECRET_LENGTH = 32;
|
||||||
@@ -12,20 +12,13 @@ const authEnabled = isAuthEnabled();
|
|||||||
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
|
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
|
||||||
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
|
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
|
||||||
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
|
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 homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
|
||||||
const homepageAuthPasswordDigest = homepageAuthPassword
|
const homepageAuthPasswordDigest = homepageAuthPassword
|
||||||
? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
|
? createHash("sha256").update(homepageAuthPassword, "utf8").digest()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Map HOMEPAGE_* envs to what NextAuth expects
|
// Also done in instrumentation.js
|
||||||
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
|
applyNextAuthEnv();
|
||||||
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
|
|
||||||
}
|
|
||||||
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
|
|
||||||
process.env.NEXTAUTH_URL = homepageExternalUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
|
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
|
||||||
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
|
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export default async function handler(req, res) {
|
|||||||
return res.status(404).end("Not Found");
|
return res.status(404).end("Not Found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method check precedes auth so CORS preflights aren't answered with a 401.
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.setHeader("Allow", "POST");
|
||||||
|
return res.status(405).end("Method Not Allowed");
|
||||||
|
}
|
||||||
|
|
||||||
const tokenError = mcpTokenConfigError();
|
const tokenError = mcpTokenConfigError();
|
||||||
if (tokenError) {
|
if (tokenError) {
|
||||||
createLogger("mcp").error(tokenError);
|
createLogger("mcp").error(tokenError);
|
||||||
@@ -25,11 +31,6 @@ export default async function handler(req, res) {
|
|||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
return res.status(405).end("Method Not Allowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = handleMcpRequest(req.body);
|
const response = handleMcpRequest(req.body);
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return res.status(202).end();
|
return res.status(202).end();
|
||||||
|
|||||||
@@ -214,4 +214,38 @@ describe("pages/api/mcp", () => {
|
|||||||
expect(res.status).toHaveBeenCalledWith(405);
|
expect(res.status).toHaveBeenCalledWith(405);
|
||||||
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers unauthenticated CORS preflights with 405 rather than 401", async () => {
|
||||||
|
process.env.HOMEPAGE_MCP_ENABLED = "true";
|
||||||
|
process.env.HOMEPAGE_MCP_TOKEN = "mcp-tok-0123456789abcdefghijklmnopqrstuv";
|
||||||
|
const handler = await loadHandler();
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
// a preflight never carries the Authorization header the browser strips
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
method: "OPTIONS",
|
||||||
|
headers: {
|
||||||
|
origin: "https://claude.ai",
|
||||||
|
"access-control-request-method": "POST",
|
||||||
|
"access-control-request-headers": "authorization,content-type",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(405);
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("Allow", "POST");
|
||||||
|
expect(getServerSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still returns 404 for non-POST requests while disabled", async () => {
|
||||||
|
delete process.env.HOMEPAGE_MCP_ENABLED;
|
||||||
|
const handler = await loadHandler();
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
await handler({ method: "OPTIONS", headers: {} }, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { getProviders, signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { BiShieldQuarter } from "react-icons/bi";
|
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">
|
<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" />
|
<BiShieldQuarter className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
|
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">Authentication error</h1>
|
||||||
Authentication not configured
|
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">Auth is disabled or misconfigured.</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
@@ -202,7 +200,16 @@ export default function SignIn({ providers, settings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
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 homepageSettings = getSettings();
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
PUBLIC_SIGN_IN_SETTINGS.filter((key) => Object.prototype.hasOwnProperty.call(homepageSettings, key)).map((key) => [
|
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 classNames from "classnames";
|
||||||
import BookmarksGroup from "components/bookmarks/group";
|
import BookmarksGroup from "components/bookmarks/group";
|
||||||
import ErrorBoundary from "components/errorboundry";
|
import ErrorBoundary from "components/errorboundry";
|
||||||
@@ -175,7 +174,7 @@ function Index({ initialSettings, fallback }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-2 text-theme-100 dark:text-theme-200">
|
<div className="p-2 text-theme-100 dark:text-theme-200">
|
||||||
<pre className="opacity-50 font-bold pb-2">
|
<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>
|
||||||
<pre className="font-italic">Check logs for details.</pre>
|
<pre className="font-italic">Check logs for details.</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
import { SettingsContext } from "utils/contexts/settings";
|
import { SettingsContext } from "utils/contexts/settings";
|
||||||
|
|
||||||
export function renderWithProviders(ui, { settings = {} } = {}) {
|
export function renderWithProviders(ui, { settings = {} } = {}) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
||||||
import {
|
import {
|
||||||
cleanServiceGroups,
|
cleanServiceGroups,
|
||||||
@@ -12,6 +10,7 @@ import {
|
|||||||
servicesFromKubernetes,
|
servicesFromKubernetes,
|
||||||
} from "utils/config/service-helpers";
|
} from "utils/config/service-helpers";
|
||||||
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares services by weight then by name.
|
* Compares services by weight then by name.
|
||||||
@@ -30,7 +29,7 @@ export async function bookmarksResponse() {
|
|||||||
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
|
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
|
||||||
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
|
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const bookmarks = yaml.load(fileContents);
|
const bookmarks = loadYaml(fileContents);
|
||||||
|
|
||||||
if (!bookmarks) return [];
|
if (!bookmarks) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ vi.mock("fs", () => ({
|
|||||||
promises: fs,
|
promises: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => config);
|
vi.mock("utils/config/config", () => config);
|
||||||
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
|
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { fs, yaml } = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("fs", () => fs);
|
vi.mock("fs", () => fs);
|
||||||
vi.mock("js-yaml", () => ({ default: yaml, ...yaml }));
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
|
|
||||||
describe("utils/config/config checkAndCopyConfig", () => {
|
describe("utils/config/config checkAndCopyConfig", () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
import cache from "memory-cache";
|
import cache from "memory-cache";
|
||||||
|
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
const cacheKey = "homepageEnvironmentVariables";
|
const cacheKey = "homepageEnvironmentVariables";
|
||||||
const homepageVarPrefix = "HOMEPAGE_VAR_";
|
const homepageVarPrefix = "HOMEPAGE_VAR_";
|
||||||
const homepageFilePrefix = "HOMEPAGE_FILE_";
|
const homepageFilePrefix = "HOMEPAGE_FILE_";
|
||||||
@@ -42,7 +43,7 @@ export default function checkAndCopyConfig(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
yaml.load(readFileSync(configYaml, "utf8"));
|
loadYaml(readFileSync(configYaml, "utf8"));
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ...e, config };
|
return { ...e, config };
|
||||||
@@ -85,7 +86,7 @@ export function getSettings() {
|
|||||||
const settingsYaml = join(CONF_DIR, "settings.yaml");
|
const settingsYaml = join(CONF_DIR, "settings.yaml");
|
||||||
const rawFileContents = readFileSync(settingsYaml, "utf8");
|
const rawFileContents = readFileSync(settingsYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const initialSettings = yaml.load(fileContents) ?? {};
|
const initialSettings = loadYaml(fileContents) ?? {};
|
||||||
|
|
||||||
if (initialSettings.layout) {
|
if (initialSettings.layout) {
|
||||||
// support yaml list but old spec was object so convert to that
|
// support yaml list but old spec was object so convert to that
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export function getDefaultDockerArgs(platform = process.platform) {
|
export function getDefaultDockerArgs(platform = process.platform) {
|
||||||
if (platform !== "win32" && platform !== "darwin") {
|
if (platform !== "win32" && platform !== "darwin") {
|
||||||
@@ -19,7 +18,7 @@ export default function getDockerArguments(server) {
|
|||||||
const configFile = path.join(CONF_DIR, "docker.yaml");
|
const configFile = path.join(CONF_DIR, "docker.yaml");
|
||||||
const rawConfigData = readFileSync(configFile, "utf8");
|
const rawConfigData = readFileSync(configFile, "utf8");
|
||||||
const configData = substituteEnvironmentVars(rawConfigData);
|
const configData = substituteEnvironmentVars(rawConfigData);
|
||||||
const servers = yaml.load(configData);
|
const servers = loadYaml(configData);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return getDefaultDockerArgs();
|
return getDefaultDockerArgs();
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ vi.mock("fs", () => ({
|
|||||||
readFileSync: fs.readFileSync,
|
readFileSync: fs.readFileSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
default: checkAndCopyConfig,
|
default: checkAndCopyConfig,
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
|
import { isIPv6 } from "net";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
|
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export function getKubernetes() {
|
export function getKubernetes() {
|
||||||
checkAndCopyConfig("kubernetes.yaml");
|
checkAndCopyConfig("kubernetes.yaml");
|
||||||
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
|
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
|
||||||
const rawConfigData = readFileSync(configFile, "utf8");
|
const rawConfigData = readFileSync(configFile, "utf8");
|
||||||
const configData = substituteEnvironmentVars(rawConfigData);
|
const configData = substituteEnvironmentVars(rawConfigData);
|
||||||
return yaml.load(configData);
|
return loadYaml(configData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getKubeConfig = () => {
|
export const getKubeConfig = () => {
|
||||||
@@ -21,6 +22,10 @@ export const getKubeConfig = () => {
|
|||||||
switch (config?.mode) {
|
switch (config?.mode) {
|
||||||
case "cluster":
|
case "cluster":
|
||||||
kc.loadFromCluster();
|
kc.loadFromCluster();
|
||||||
|
// node < 26 can't match an IPv6 host against an IP SAN, so verify against the DNS SAN instead (nodejs/node#64032)
|
||||||
|
if (isIPv6(process.env.KUBERNETES_SERVICE_HOST ?? "")) {
|
||||||
|
kc.clusters = kc.clusters.map((cluster) => ({ ...cluster, tlsServerName: "kubernetes.default.svc" }));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "default":
|
case "default":
|
||||||
kc.loadFromDefault();
|
kc.loadFromDefault();
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ vi.mock("fs", () => ({
|
|||||||
readFileSync: fs.readFileSync,
|
readFileSync: fs.readFileSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
default: checkAndCopyConfig,
|
default: checkAndCopyConfig,
|
||||||
@@ -46,6 +43,7 @@ vi.mock("@kubernetes/client-node", () => ({
|
|||||||
ApiextensionsV1Api: class ApiextensionsV1Api {},
|
ApiextensionsV1Api: class ApiextensionsV1Api {},
|
||||||
KubeConfig: class KubeConfig {
|
KubeConfig: class KubeConfig {
|
||||||
loadFromCluster() {
|
loadFromCluster() {
|
||||||
|
this.clusters = [{ name: "inCluster", server: "https://host:443", skipTLSVerify: false }];
|
||||||
return kube.loadFromCluster();
|
return kube.loadFromCluster();
|
||||||
}
|
}
|
||||||
loadFromDefault() {
|
loadFromDefault() {
|
||||||
@@ -88,6 +86,27 @@ describe("utils/config/kubernetes", () => {
|
|||||||
expect(kc2).not.toBeNull();
|
expect(kc2).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getKubeConfig sets tlsServerName when the in-cluster host is IPv6", () => {
|
||||||
|
vi.stubEnv("KUBERNETES_SERVICE_HOST", "fd00:1018:2000::1");
|
||||||
|
yaml.load.mockReturnValueOnce({ mode: "cluster" });
|
||||||
|
|
||||||
|
expect(getKubeConfig().clusters[0]).toMatchObject({
|
||||||
|
name: "inCluster",
|
||||||
|
tlsServerName: "kubernetes.default.svc",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getKubeConfig leaves tlsServerName unset for non-IPv6 in-cluster hosts", () => {
|
||||||
|
vi.stubEnv("KUBERNETES_SERVICE_HOST", "10.43.0.1");
|
||||||
|
yaml.load.mockReturnValueOnce({ mode: "cluster" });
|
||||||
|
|
||||||
|
expect(getKubeConfig().clusters[0].tlsServerName).toBeUndefined();
|
||||||
|
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
it("checkCRD returns true when the CRD exists", async () => {
|
it("checkCRD returns true when the CRD exists", async () => {
|
||||||
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
|
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
|
||||||
const logger = { error: vi.fn() };
|
const logger = { error: vi.fn() };
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export function getProxmoxConfig() {
|
export function getProxmoxConfig() {
|
||||||
checkAndCopyConfig("proxmox.yaml");
|
checkAndCopyConfig("proxmox.yaml");
|
||||||
const configFile = path.join(CONF_DIR, "proxmox.yaml");
|
const configFile = path.join(CONF_DIR, "proxmox.yaml");
|
||||||
const rawConfigData = readFileSync(configFile, "utf8");
|
const rawConfigData = readFileSync(configFile, "utf8");
|
||||||
const configData = substituteEnvironmentVars(rawConfigData);
|
const configData = substituteEnvironmentVars(rawConfigData);
|
||||||
return yaml.load(configData);
|
return loadYaml(configData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
|
|||||||
readFileSync: fs.readFileSync,
|
readFileSync: fs.readFileSync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => ({
|
vi.mock("utils/config/config", () => ({
|
||||||
default: checkAndCopyConfig,
|
default: checkAndCopyConfig,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { promises as fs } from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
|
||||||
import getDockerArguments from "utils/config/docker";
|
import getDockerArguments from "utils/config/docker";
|
||||||
import { getKubeConfig } from "utils/config/kubernetes";
|
import { getKubeConfig } from "utils/config/kubernetes";
|
||||||
import * as shvl from "utils/config/shvl";
|
import * as shvl from "utils/config/shvl";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
import kubernetes from "utils/kubernetes/export";
|
import kubernetes from "utils/kubernetes/export";
|
||||||
import createLogger from "utils/logger";
|
import createLogger from "utils/logger";
|
||||||
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
@@ -56,7 +56,7 @@ export async function servicesFromConfig() {
|
|||||||
const servicesYaml = path.join(CONF_DIR, "services.yaml");
|
const servicesYaml = path.join(CONF_DIR, "services.yaml");
|
||||||
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
|
const rawFileContents = await fs.readFile(servicesYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const services = yaml.load(fileContents);
|
const services = loadYaml(fileContents);
|
||||||
return parseServicesToGroups(services);
|
return parseServicesToGroups(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function servicesFromDocker() {
|
|||||||
const dockerYaml = path.join(CONF_DIR, "docker.yaml");
|
const dockerYaml = path.join(CONF_DIR, "docker.yaml");
|
||||||
const rawDockerFileContents = await fs.readFile(dockerYaml, "utf8");
|
const rawDockerFileContents = await fs.readFile(dockerYaml, "utf8");
|
||||||
const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);
|
const dockerFileContents = substituteEnvironmentVars(rawDockerFileContents);
|
||||||
const servers = yaml.load(dockerFileContents);
|
const servers = loadYaml(dockerFileContents);
|
||||||
|
|
||||||
if (!servers) {
|
if (!servers) {
|
||||||
return [];
|
return [];
|
||||||
@@ -589,6 +589,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
"grafana",
|
"grafana",
|
||||||
"gluetun",
|
"gluetun",
|
||||||
"vikunja",
|
"vikunja",
|
||||||
|
"pulse",
|
||||||
].includes(type)
|
].includes(type)
|
||||||
) {
|
) {
|
||||||
widget.version = parseVersionForUrl(version);
|
widget.version = parseVersionForUrl(version);
|
||||||
|
|||||||
@@ -70,10 +70,7 @@ vi.mock("fs", () => ({
|
|||||||
promises: fs,
|
promises: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => config);
|
vi.mock("utils/config/config", () => config);
|
||||||
vi.mock("dockerode", () => ({ default: Docker }));
|
vi.mock("dockerode", () => ({ default: Docker }));
|
||||||
@@ -266,6 +263,7 @@ describe("utils/config/service-helpers", () => {
|
|||||||
widgets: [
|
widgets: [
|
||||||
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
|
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
|
||||||
{ type: "beszel", version: "2", systemId: "sys" },
|
{ type: "beszel", version: "2", systemId: "sys" },
|
||||||
|
{ type: "pulse", version: "2" },
|
||||||
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
|
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
|
||||||
{ type: "crowdsec", limit24h: "true" },
|
{ type: "crowdsec", limit24h: "true" },
|
||||||
{ type: "docker", server: "docker-local", container: "c1" },
|
{ type: "docker", server: "docker-local", container: "c1" },
|
||||||
@@ -345,6 +343,7 @@ describe("utils/config/service-helpers", () => {
|
|||||||
expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
|
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 === "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 === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
|
||||||
expect(widgets.find((w) => w.type === "docker")).toEqual(
|
expect(widgets.find((w) => w.type === "docker")).toEqual(
|
||||||
expect.objectContaining({ server: "docker-local", container: "c1" }),
|
expect.objectContaining({ server: "docker-local", container: "c1" }),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
|
||||||
|
|
||||||
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
export async function widgetsFromConfig() {
|
export async function widgetsFromConfig() {
|
||||||
checkAndCopyConfig("widgets.yaml");
|
checkAndCopyConfig("widgets.yaml");
|
||||||
@@ -11,7 +10,7 @@ export async function widgetsFromConfig() {
|
|||||||
const widgetsYaml = path.join(CONF_DIR, "widgets.yaml");
|
const widgetsYaml = path.join(CONF_DIR, "widgets.yaml");
|
||||||
const rawFileContents = await fs.readFile(widgetsYaml, "utf8");
|
const rawFileContents = await fs.readFile(widgetsYaml, "utf8");
|
||||||
const fileContents = substituteEnvironmentVars(rawFileContents);
|
const fileContents = substituteEnvironmentVars(rawFileContents);
|
||||||
const widgets = yaml.load(fileContents);
|
const widgets = loadYaml(fileContents);
|
||||||
|
|
||||||
if (!widgets) return [];
|
if (!widgets) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ vi.mock("fs", () => ({
|
|||||||
promises: fs,
|
promises: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("js-yaml", () => ({
|
vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));
|
||||||
default: yaml,
|
|
||||||
...yaml,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("utils/config/config", () => config);
|
vi.mock("utils/config/config", () => config);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as yaml from "js-yaml";
|
||||||
|
|
||||||
|
const EMPTY_DOCUMENT_ERROR = "expected a document, but the input is empty";
|
||||||
|
const DEFAULT_SCHEMA = yaml.CORE_SCHEMA.withTags(yaml.timestampTag, yaml.mergeTag);
|
||||||
|
|
||||||
|
export function loadYaml(input, options) {
|
||||||
|
try {
|
||||||
|
return yaml.load(input, { schema: DEFAULT_SCHEMA, ...options });
|
||||||
|
} catch (error) {
|
||||||
|
// js-yaml v4 returned undefined for empty and comment-only documents.
|
||||||
|
if (error?.reason === EMPTY_DOCUMENT_ERROR) return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadYaml } from "./yaml";
|
||||||
|
|
||||||
|
describe("utils/config/yaml", () => {
|
||||||
|
it.each(["", " \n", "# comment only\n"])("loads an empty document from %j as undefined", (input) => {
|
||||||
|
expect(loadYaml(input)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a populated document", () => {
|
||||||
|
expect(loadYaml("title: Homepage\n")).toEqual({ title: "Homepage" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves v4 merge key behavior", () => {
|
||||||
|
expect(loadYaml("defaults: &defaults\n href: https://example.com\nservice:\n <<: *defaults\n")).toEqual({
|
||||||
|
defaults: { href: "https://example.com" },
|
||||||
|
service: { href: "https://example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves v4 timestamp behavior without enabling YAML 1.1 booleans", () => {
|
||||||
|
expect(loadYaml("date: 2026-08-21\nenabled: yes\n")).toEqual({
|
||||||
|
date: new Date("2026-08-21T00:00:00.000Z"),
|
||||||
|
enabled: "yes",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects invalid YAML", () => {
|
||||||
|
expect(() => loadYaml("value: [\n")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,6 @@ export function ColorProvider({ initialTheme, children }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
|
if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [initialTheme]);
|
}, [initialTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export function ThemeProvider({ initialTheme, children }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
|
if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [initialTheme]);
|
}, [initialTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
export function isAuthEnabled() {
|
export function isAuthEnabled() {
|
||||||
return process.env.HOMEPAGE_AUTH_ENABLED === "true";
|
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 logger = createLogger("resource-helpers");
|
||||||
const kc = getKubeConfig();
|
const kc = getKubeConfig();
|
||||||
|
|
||||||
const getSchemaFromGateway = async (parentRef) => {
|
const getSchemaFromGateway = async (parentRef, routeNamespace) => {
|
||||||
const crd = kc.makeApiClient(CustomObjectsApi);
|
const crd = kc.makeApiClient(CustomObjectsApi);
|
||||||
const schema = await crd
|
const schema = await crd
|
||||||
.getNamespacedCustomObject({
|
.getNamespacedCustomObject({
|
||||||
group: HTTPROUTE_API_GROUP,
|
group: HTTPROUTE_API_GROUP,
|
||||||
version: HTTPROUTE_API_VERSION,
|
version: HTTPROUTE_API_VERSION,
|
||||||
namespace: parentRef.namespace,
|
// parentRef namespace is optional, defaults to the route's namespace
|
||||||
|
namespace: parentRef.namespace ?? routeNamespace,
|
||||||
plural: "gateways",
|
plural: "gateways",
|
||||||
name: parentRef.name,
|
name: parentRef.name,
|
||||||
})
|
})
|
||||||
@@ -31,7 +32,7 @@ const getSchemaFromGateway = async (parentRef) => {
|
|||||||
return listener.protocol.toLowerCase();
|
return listener.protocol.toLowerCase();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.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);
|
logger.debug(error);
|
||||||
|
|
||||||
return "http";
|
return "http";
|
||||||
@@ -48,7 +49,7 @@ async function getUrlFromHttpRoute(resource) {
|
|||||||
if (resource.spec.rules[0].matches[0].path.type !== "RegularExpression") {
|
if (resource.spec.rules[0].matches[0].path.type !== "RegularExpression") {
|
||||||
const urlHost = resource.spec.hostnames[0];
|
const urlHost = resource.spec.hostnames[0];
|
||||||
const urlPath = resource.spec.rules[0].matches[0].path.value;
|
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}`;
|
url = `${urlSchema}://${urlHost}${urlPath}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,36 @@ describe("utils/kubernetes/resource-helpers", () => {
|
|||||||
expect(service.href).toBe("https://example.com/r");
|
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 () => {
|
it("falls back to http when the gateway listener protocol cannot be resolved", async () => {
|
||||||
const kc = getKubeConfig();
|
const kc = getKubeConfig();
|
||||||
const crd = kc.makeApiClient();
|
const crd = kc.makeApiClient();
|
||||||
@@ -169,10 +199,41 @@ describe("utils/kubernetes/resource-helpers", () => {
|
|||||||
|
|
||||||
const service = await constructedServiceFromResource(resource);
|
const service = await constructedServiceFromResource(resource);
|
||||||
expect(service.href).toBe("http://example.com/r");
|
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();
|
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 () => {
|
it("logs and recovers when environment substitution yields invalid json", async () => {
|
||||||
substituteEnvironmentVars.mockImplementationOnce(() => "{bad json");
|
substituteEnvironmentVars.mockImplementationOnce(() => "{bad json");
|
||||||
|
|
||||||
|
|||||||
+6
-13
@@ -18,23 +18,16 @@ function combineMessageAndSplat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function messageFormatter(logInfo) {
|
function messageFormatter(logInfo) {
|
||||||
if (logInfo.label) {
|
const label = logInfo.label ? `<${logInfo.label}> ` : "";
|
||||||
if (logInfo.stack) {
|
// e.g. fetch errors say nothing useful without the cause
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.stack}`;
|
const cause = logInfo.cause ? `\ncaused by: ${logInfo.cause.stack ?? logInfo.cause}` : "";
|
||||||
}
|
return `[${logInfo.timestamp}] ${logInfo.level}: ${label}${logInfo.stack || logInfo.message}${cause}`;
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: <${logInfo.label}> ${logInfo.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logInfo.stack) {
|
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.stack}`;
|
|
||||||
}
|
|
||||||
return `[${logInfo.timestamp}] ${logInfo.level}: ${logInfo.message}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConsoleLogger() {
|
function getConsoleLogger() {
|
||||||
return new winston.transports.Console({
|
return new winston.transports.Console({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.errors({ stack: true }),
|
winston.format.errors({ stack: true, cause: true }),
|
||||||
combineMessageAndSplat(),
|
combineMessageAndSplat(),
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
winston.format.colorize(),
|
winston.format.colorize(),
|
||||||
@@ -51,7 +44,7 @@ function getFileLogger() {
|
|||||||
|
|
||||||
return new winston.transports.File({
|
return new winston.transports.File({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.errors({ stack: true }),
|
winston.format.errors({ stack: true, cause: true }),
|
||||||
combineMessageAndSplat(),
|
combineMessageAndSplat(),
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
winston.format.printf(messageFormatter),
|
winston.format.printf(messageFormatter),
|
||||||
|
|||||||
@@ -170,6 +170,16 @@ describe("utils/logger", () => {
|
|||||||
});
|
});
|
||||||
expect(plainMsg).toBe("[t] info: hello");
|
expect(plainMsg).toBe("[t] info: hello");
|
||||||
|
|
||||||
|
const causeMsg = formatter({
|
||||||
|
timestamp: "t",
|
||||||
|
level: "error",
|
||||||
|
label: "x",
|
||||||
|
stack: "STACK",
|
||||||
|
message: "fetch failed",
|
||||||
|
cause: { stack: "CAUSE STACK" },
|
||||||
|
});
|
||||||
|
expect(causeMsg).toBe("[t] error: <x> STACK\ncaused by: CAUSE STACK");
|
||||||
|
|
||||||
const out = splat.transform(
|
const out = splat.transform(
|
||||||
{
|
{
|
||||||
message: "Hello %s",
|
message: "Hello %s",
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
|
|
||||||
import { CONF_DIR } from "utils/config/config";
|
import { CONF_DIR } from "utils/config/config";
|
||||||
|
import { loadYaml } from "utils/config/yaml";
|
||||||
|
|
||||||
const PROTOCOL_VERSION = "2025-11-25";
|
const PROTOCOL_VERSION = "2025-11-25";
|
||||||
const SERVER_INFO = {
|
const SERVER_INFO = {
|
||||||
@@ -118,7 +119,7 @@ function readConfig(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseYamlConfig(file) {
|
function parseYamlConfig(file) {
|
||||||
const parsed = yaml.load(readConfig(file) || "");
|
const parsed = loadYaml(readConfig(file) || "");
|
||||||
return parsed ?? [];
|
return parsed ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ function validateYaml(file, content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
yaml.load(content || "");
|
loadYaml(content || "");
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ export default function Component({ service }) {
|
|||||||
widget.fields = widget.fields.slice(0, MAX_FIELDS);
|
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")} />;
|
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 =
|
const error =
|
||||||
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
|
containersError ?? imagesError ?? updatesError ?? containers?.detail ?? images?.detail ?? updates?.detail;
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ describe("widgets/arcane/component", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows an environment required error when env is missing", () => {
|
it("shows an environment required error when env is missing", () => {
|
||||||
|
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
|
||||||
|
|
||||||
renderWithProviders(<Component service={{ widget: { type: "arcane" } }} />, {
|
renderWithProviders(<Component service={{ widget: { type: "arcane" } }} />, {
|
||||||
settings: { hideErrors: false },
|
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();
|
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,34 +8,43 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
|
const isV2 = widget.version === 2;
|
||||||
|
|
||||||
const { data: usersData, error: usersError } = useWidgetAPI(widget, "users");
|
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 { 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);
|
const { data: failedLoginsData, error: failedLoginsError } = useWidgetAPI(widget, failedLoginsEndpoint);
|
||||||
|
|
||||||
if (usersError || loginsError || failedLoginsError) {
|
const eventsDataEndpoint = isV2 ? "datav2" : "";
|
||||||
const finalError = usersError ?? loginsError ?? failedLoginsError;
|
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} />;
|
return <Container service={service} error={finalError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!usersData || !loginsData || !failedLoginsData) {
|
const hasNoData = isV2 ? !usersData || !eventsData : !usersData || !loginsData || !failedLoginsData;
|
||||||
|
|
||||||
|
if (hasNoData) {
|
||||||
return (
|
return (
|
||||||
<Container service={service}>
|
<Container service={service}>
|
||||||
<Block label="authentik.users" />
|
<Block label="authentik.users" />
|
||||||
<Block label="authentik.loginsLast24H" />
|
<Block label="authentik.loginsLast24H" />
|
||||||
<Block label="authentik.failedLoginsLast24H" />
|
<Block label="authentik.failedLoginsLast24H" />
|
||||||
|
{isV2 && <Block label="authentik.authorizationsLast24H" />}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loginsLast24H;
|
let loginsLast24H;
|
||||||
let failedLoginsLast24H;
|
let failedLoginsLast24H;
|
||||||
|
let authorizationsLast24H;
|
||||||
switch (widget.version) {
|
switch (widget.version) {
|
||||||
case 1:
|
// v1 is default
|
||||||
|
default:
|
||||||
const yesterday = new Date(Date.now()).setHours(-24);
|
const yesterday = new Date(Date.now()).setHours(-24);
|
||||||
loginsLast24H = loginsData.reduce(
|
loginsLast24H = loginsData.reduce(
|
||||||
(total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),
|
(total, current) => (current.x_cord >= yesterday ? total + current.y_cord : total),
|
||||||
@@ -47,13 +56,28 @@ export default function Component({ service }) {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
loginsLast24H =
|
const events = Array.isArray(eventsData) ? eventsData : [];
|
||||||
loginsData.reduce?.(
|
const result = events.reduce(
|
||||||
(total, current) => (current?.count && current?.action === "login" ? total + current.count : total),
|
(acc, current) => {
|
||||||
0,
|
if (!current?.count || !current?.action) {
|
||||||
) || 0;
|
return acc;
|
||||||
failedLoginsLast24H =
|
}
|
||||||
failedLoginsData.reduce?.((total, current) => (current?.count ? total + current.count : total), 0) || 0;
|
|
||||||
|
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;
|
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.users" value={t("common.number", { value: usersData.pagination.count })} />
|
||||||
<Block label="authentik.loginsLast24H" value={t("common.number", { value: loginsLast24H })} />
|
<Block label="authentik.loginsLast24H" value={t("common.number", { value: loginsLast24H })} />
|
||||||
<Block label="authentik.failedLoginsLast24H" value={t("common.number", { value: failedLoginsLast24H })} />
|
<Block label="authentik.failedLoginsLast24H" value={t("common.number", { value: failedLoginsLast24H })} />
|
||||||
|
{isV2 && (
|
||||||
|
<Block label="authentik.authorizationsLast24H" value={t("common.number", { value: authorizationsLast24H })} />
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,24 +30,31 @@ describe("widgets/authentik/component", () => {
|
|||||||
settings: { hideErrors: false },
|
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.users")).toBeInTheDocument();
|
||||||
expect(screen.getByText("authentik.loginsLast24H")).toBeInTheDocument();
|
expect(screen.getByText("authentik.loginsLast24H")).toBeInTheDocument();
|
||||||
expect(screen.getByText("authentik.failedLoginsLast24H")).toBeInTheDocument();
|
expect(screen.getByText("authentik.failedLoginsLast24H")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("authentik.authorizationsLast24H")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("computes v2 login/failed counts from action data", () => {
|
it("computes v2 login/failed counts from action data", () => {
|
||||||
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
useWidgetAPI.mockImplementation((widget, endpoint) => {
|
||||||
if (endpoint === "users") return { data: { pagination: { count: 10 } }, error: undefined };
|
if (endpoint === "users") return { data: { pagination: { count: 10 } }, error: undefined };
|
||||||
if (endpoint === "loginv2")
|
if (endpoint === "datav2")
|
||||||
return {
|
return {
|
||||||
data: [
|
data: [
|
||||||
{ action: "login", count: 2 },
|
{ action: "login", count: 2 },
|
||||||
|
{ action: "login", count: 4 },
|
||||||
{ action: "logout", count: 9 },
|
{ 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,
|
error: undefined,
|
||||||
};
|
};
|
||||||
if (endpoint === "login_failedv2") return { data: [{ count: 3 }, { count: null }], error: undefined };
|
|
||||||
return { data: undefined, error: undefined };
|
return { data: undefined, error: undefined };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,10 +62,11 @@ describe("widgets/authentik/component", () => {
|
|||||||
settings: { hideErrors: false },
|
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.users", 10);
|
||||||
expectBlockValue(container, "authentik.loginsLast24H", 2);
|
expectBlockValue(container, "authentik.loginsLast24H", 6);
|
||||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 3);
|
expectBlockValue(container, "authentik.failedLoginsLast24H", 3);
|
||||||
|
expectBlockValue(container, "authentik.authorizationsLast24H", 18);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("computes v1 login/failed counts for entries within the last 24h window", () => {
|
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.loginsLast24H", 2);
|
||||||
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
|
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: {
|
login: {
|
||||||
endpoint: "events/events/per_month/?action=login",
|
endpoint: "events/events/per_month/?action=login",
|
||||||
},
|
},
|
||||||
loginv2: {
|
|
||||||
endpoint: "events/events/volume/?action=login&&history_days=1",
|
|
||||||
},
|
|
||||||
login_failed: {
|
login_failed: {
|
||||||
endpoint: "events/events/per_month/?action=login_failed",
|
endpoint: "events/events/per_month/?action=login_failed",
|
||||||
},
|
},
|
||||||
login_failedv2: {
|
datav2: {
|
||||||
endpoint: "events/events/volume/?action=login_failed&&history_days=1",
|
endpoint: "events/events/volume/?actions=login&actions=login_failed&actions=authorize_application&history_days=1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user