mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-04 01:01:22 -07:00
Compare commits
30 Commits
v1.12.0
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d1d31bab | ||
|
|
250c1a3bf0 | ||
|
|
1c9d7aa8c8 | ||
|
|
814d7b229b | ||
|
|
b908707e11 | ||
|
|
217fa73ef9 | ||
|
|
996778e164 | ||
|
|
36d799dec3 | ||
|
|
0b0d5d8428 | ||
|
|
f8f6f64b5a | ||
|
|
5e16e11419 | ||
|
|
caca855d4d | ||
|
|
fa119e65c4 | ||
|
|
769b980003 | ||
|
|
e44715ebb6 | ||
|
|
11a1f094fd | ||
|
|
d6e7e7e790 | ||
|
|
24cb274e03 | ||
|
|
af852e748a | ||
|
|
0ea5c3fb68 | ||
|
|
5ede96d6ce | ||
|
|
c50bc8601d | ||
|
|
463bb4e306 | ||
|
|
4c3c4805c8 | ||
|
|
a81ac47be9 | ||
|
|
36b909d4a4 | ||
|
|
a7fe80a399 | ||
|
|
0b61b6c1b8 | ||
|
|
7b552f5080 | ||
|
|
0f767d14bb |
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -17,9 +17,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@7ca9c452bfe9197d3bb7fa83a4d7e2b0c9ae835d # v2
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
52
.github/workflows/docker-publish.yml
vendored
52
.github/workflows/docker-publish.yml
vendored
@@ -17,44 +17,12 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
|
||||||
name: Linting Checks
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: 3.x
|
|
||||||
|
|
||||||
- name: Check files
|
|
||||||
uses: pre-commit/action@v3.0.1
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v5
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Lint frontend
|
|
||||||
run: pnpm run lint
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Docker Build & Push
|
name: Docker Build & Push
|
||||||
if: github.repository == 'gethomepage/homepage'
|
if: github.repository == 'gethomepage/homepage'
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: [ pre-commit ]
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -62,11 +30,11 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.IMAGE_NAME }}
|
${{ env.IMAGE_NAME }}
|
||||||
@@ -84,7 +52,7 @@ jobs:
|
|||||||
latest=auto
|
latest=auto
|
||||||
|
|
||||||
- name: Next.js build cache
|
- name: Next.js build cache
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||||
with:
|
with:
|
||||||
path: .next/cache
|
path: .next/cache
|
||||||
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}
|
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}
|
||||||
@@ -92,13 +60,13 @@ jobs:
|
|||||||
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -115,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -123,20 +91,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v4.0.0
|
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|||||||
34
.github/workflows/docs-publish.yml
vendored
34
.github/workflows/docs-publish.yml
vendored
@@ -14,32 +14,18 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
|
||||||
name: Linting Checks
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
- name: Install python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: 3.x
|
|
||||||
- name: Check files
|
|
||||||
uses: pre-commit/action@v3.0.1
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Test Build Docs
|
name: Test Build Docs
|
||||||
if: github.repository == 'gethomepage/homepage' && github.event_name == 'pull_request'
|
if: github.repository == 'gethomepage/homepage' && github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
|
||||||
- pre-commit
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7
|
||||||
- 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
|
||||||
@@ -50,21 +36,19 @@ jobs:
|
|||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
needs:
|
|
||||||
- pre-commit
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v5
|
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7
|
||||||
- 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
|
||||||
- uses: actions/upload-pages-artifact@v4
|
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
|
||||||
with:
|
with:
|
||||||
path: site
|
path: site
|
||||||
- uses: actions/deploy-pages@v4
|
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||||
id: deployment
|
id: deployment
|
||||||
|
|||||||
41
.github/workflows/lint.yml
vendored
Normal file
41
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
merge_group:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Linting Checks
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
|
- name: Install python
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
|
with:
|
||||||
|
python-version: 3.x
|
||||||
|
|
||||||
|
- name: Check files
|
||||||
|
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Lint frontend
|
||||||
|
run: pnpm run lint
|
||||||
2
.github/workflows/pr-quality.yml
vendored
2
.github/workflows/pr-quality.yml
vendored
@@ -13,6 +13,6 @@ jobs:
|
|||||||
anti-slop:
|
anti-slop:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: peakoss/anti-slop@v0
|
- uses: peakoss/anti-slop@a5a4b2440c9de6f65b64f0718a0136a1fdb04f6f # v0
|
||||||
with:
|
with:
|
||||||
max-failures: 4
|
max-failures: 4
|
||||||
|
|||||||
2
.github/workflows/reaction-comments.yml
vendored
2
.github/workflows/reaction-comments.yml
vendored
@@ -15,4 +15,4 @@ jobs:
|
|||||||
action:
|
action:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/reaction-comments@v4
|
- uses: dessant/reaction-comments@e86d247c12bd5c043eec379a1a4453f20cadf913 # v4
|
||||||
|
|||||||
4
.github/workflows/release-drafter.yml
vendored
4
.github/workflows/release-drafter.yml
vendored
@@ -26,14 +26,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- if: github.event_name == 'workflow_dispatch' && github.event.inputs.version != ''
|
- if: github.event_name == 'workflow_dispatch' && github.event.inputs.version != ''
|
||||||
uses: release-drafter/release-drafter@v7
|
uses: release-drafter/release-drafter@a6acf82562eee06318b77ab8cb0b11ed81c677a7 # v7
|
||||||
with:
|
with:
|
||||||
config-name: release-drafter.yml
|
config-name: release-drafter.yml
|
||||||
version: ${{ github.event.inputs.version }}
|
version: ${{ github.event.inputs.version }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- if: github.event_name != 'workflow_dispatch' || github.event.inputs.version == ''
|
- if: github.event_name != 'workflow_dispatch' || github.event.inputs.version == ''
|
||||||
uses: release-drafter/release-drafter@v7
|
uses: release-drafter/release-drafter@a6acf82562eee06318b77ab8cb0b11ed81c677a7 # v7
|
||||||
with:
|
with:
|
||||||
config-name: release-drafter.yml
|
config-name: release-drafter.yml
|
||||||
env:
|
env:
|
||||||
|
|||||||
10
.github/workflows/repo-maintenance.yml
vendored
10
.github/workflows/repo-maintenance.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
name: 'Stale'
|
name: 'Stale'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v6
|
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6
|
||||||
with:
|
with:
|
||||||
issue-inactive-days: '30'
|
issue-inactive-days: '30'
|
||||||
pr-inactive-days: '30'
|
pr-inactive-days: '30'
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
name: 'Close Outdated Discussions'
|
name: 'Close Outdated Discussions'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
name: 'Close Unsupported Feature Requests'
|
name: 'Close Unsupported Feature Requests'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
|
|||||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -13,13 +13,13 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4]
|
shard: [1, 2, 3, 4]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 9
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
# Run Vitest directly so `--shard` is parsed as an option
|
# Run Vitest directly so `--shard` is parsed as an option
|
||||||
- run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks
|
- run: pnpm -s exec vitest run --coverage --shard ${{ matrix.shard }}/4 --pool forks
|
||||||
- name: Upload coverage reports to Codecov
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
files: ./coverage/lcov.info
|
files: ./coverage/lcov.info
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -63,65 +63,14 @@ For configuration options, examples and more, [please check out the homepage doc
|
|||||||
|
|
||||||
## Security Notice 🔒
|
## Security Notice 🔒
|
||||||
|
|
||||||
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system) and Homepage currently does not (and is not planned to) include any authentication layer itself. If Homepage is reachable from any untrusted network, it **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers. The built-in host check in Homepage is a best-effort guard and should not be treated as security when exposed publicly.
|
Please note that when using features such as widgets, Homepage can access personal information (for example from your home automation system). To keep your information private, if Homepage is reachable from any untrusted network, it:
|
||||||
|
|
||||||
## With Docker
|
1. **must** sit behind a reverse proxy (and/or VPN) that enforces authentication, TLS, and strictly validates Host headers.
|
||||||
|
2. An optional built-in OIDC login flow is available (opt-in) offering a simple “authenticated or not” guard.
|
||||||
|
|
||||||
Using docker compose:
|
## Installation
|
||||||
|
|
||||||
```yaml
|
See the [Installation](https://gethomepage.dev/installation/) section of the docs for instructions on installing Homepage via Docker, Kubernetes, Unraid, or from source.
|
||||||
services:
|
|
||||||
homepage:
|
|
||||||
image: ghcr.io/gethomepage/homepage:latest
|
|
||||||
container_name: homepage
|
|
||||||
environment:
|
|
||||||
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
|
|
||||||
PUID: 1000 # optional, your user id
|
|
||||||
PGID: 1000 # optional, your group id
|
|
||||||
ports:
|
|
||||||
- 3000:3000
|
|
||||||
volumes:
|
|
||||||
- /path/to/config:/app/config # Make sure your local config directory exists
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro # optional, for docker integrations
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
or docker run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --name homepage \
|
|
||||||
-e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev \
|
|
||||||
-e PUID=1000 \
|
|
||||||
-e PGID=1000 \
|
|
||||||
-p 3000:3000 \
|
|
||||||
-v /path/to/config:/app/config \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
|
||||||
--restart unless-stopped \
|
|
||||||
ghcr.io/gethomepage/homepage:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## From Source
|
|
||||||
|
|
||||||
First, clone the repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/gethomepage/homepage.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Then install dependencies and build the production bundle:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
If this is your first time starting, copy the `src/skeleton` directory to `config/` to populate initial example config files.
|
|
||||||
|
|
||||||
Finally, run the server in production mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /path/to/config:/app/config # Make sure your local config directory exists
|
- /path/to/config:/app/config # Make sure your local config directory exists
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations
|
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations
|
||||||
environment:
|
|
||||||
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running as non-root
|
### Running as non-root
|
||||||
@@ -38,7 +36,6 @@ services:
|
|||||||
- /path/to/config:/app/config # Make sure your local config directory exists
|
- /path/to/config:/app/config # Make sure your local config directory exists
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods
|
- /var/run/docker.sock:/var/run/docker.sock:ro # (optional) For docker integrations, see alternative methods
|
||||||
environment:
|
environment:
|
||||||
HOMEPAGE_ALLOWED_HOSTS: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts
|
|
||||||
PUID: $PUID
|
PUID: $PUID
|
||||||
PGID: $PGID
|
PGID: $PGID
|
||||||
```
|
```
|
||||||
@@ -46,7 +43,7 @@ services:
|
|||||||
### With Docker Run
|
### With Docker Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -p 3000:3000 -e HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
|
docker run -p 3000:3000 -v /path/to/config:/app/config -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/gethomepage/homepage:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Environment Secrets
|
### Using Environment Secrets
|
||||||
|
|||||||
@@ -27,14 +27,25 @@ You have a few options for deploying homepage, depending on your needs. We offer
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### `HOMEPAGE_ALLOWED_HOSTS`
|
### Security & Authentication
|
||||||
|
|
||||||
As of v1.0 there is one required environment variable to access homepage via a URL other than `localhost`, <code>HOMEPAGE_ALLOWED_HOSTS</code>. The setting helps prevent certain kinds of attacks when retrieving data from the homepage API proxy.
|
Public deployments of Homepage should be secured via a reverse proxy, VPN, or similar. As of version 2.0, Homepage supports a simple authorization gate with a password or OIDC. When enabled, Homepage will use password login by default unless OIDC variables are provided.
|
||||||
|
|
||||||
The value is a comma-separated (no spaces) list of allowed hosts (sometimes with the port) that can host your homepage install. See the [docker](docker.md), [kubernetes](k8s.md) and [source](source.md) installation pages for more information about where / how to set the variable.
|
Required environment variables for authentication:
|
||||||
|
|
||||||
`localhost:3000` and `127.0.0.1:3000` are always included, but you can add a domain or IP address to this list to allow that host such as `HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev,192.168.1.2:1234`, etc.
|
- `HOMEPAGE_AUTH_ENABLED=true`
|
||||||
|
- `HOMEPAGE_AUTH_SECRET` (random string for signing/encrypting cookies)
|
||||||
|
|
||||||
If you are seeing errors about host validation, check the homepage logs and ensure that the host exactly as output in the logs is in the `HOMEPAGE_ALLOWED_HOSTS` list.
|
For password-only login:
|
||||||
|
|
||||||
This can be disabled by setting `HOMEPAGE_ALLOWED_HOSTS` to `*` but this is not recommended. Public deployments must rely on a reverse proxy (and/or VPN) that enforces authentication, TLS, and unexpected Host headers; the built-in host check is a best-effort guard for local setups and is not a substitute for edge protections.
|
- `HOMEPAGE_AUTH_PASSWORD` (password-only login; required unless OIDC settings are provided)
|
||||||
|
|
||||||
|
For OIDC login (overrides password login):
|
||||||
|
|
||||||
|
- `HOMEPAGE_OIDC_ISSUER` (OIDC issuer URL, e.g., `https://auth.example.com/realms/homepage`)
|
||||||
|
- `HOMEPAGE_OIDC_CLIENT_ID`
|
||||||
|
- `HOMEPAGE_OIDC_CLIENT_SECRET`
|
||||||
|
- `HOMEPAGE_EXTERNAL_URL` (external URL to your Homepage instance; used for callbacks)
|
||||||
|
- Optional: `HOMEPAGE_OIDC_NAME` (display name), `HOMEPAGE_OIDC_SCOPE` (defaults to `openid email profile`)
|
||||||
|
|
||||||
|
All app pages and `/api` routes will require a signed-in session. Static assets remain public. Homepage still does not implement per-user dashboards or roles; authentication is a simple gate only.
|
||||||
|
|||||||
@@ -238,8 +238,6 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: status.podIP
|
fieldPath: status.podIP
|
||||||
- name: HOMEPAGE_ALLOWED_HOSTS
|
|
||||||
value: "$(MY_POD_IP):3000,gethomepage.dev" # See gethomepage.dev/installation/#homepage_allowed_hosts . Value before the comma is required for the k8s probe
|
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ If this is your first time starting, copy the `src/skeleton` directory to `confi
|
|||||||
Finally, run the server:
|
Finally, run the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HOMEPAGE_ALLOWED_HOSTS=gethomepage.dev:1234 pnpm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
When updating homepage versions you will need to re-build the static files i.e. repeat the process above.
|
When updating homepage versions you will need to re-build the static files i.e. repeat the process above.
|
||||||
|
|
||||||
See [HOMEPAGE_ALLOWED_HOSTS](index.md#homepage_allowed_hosts) for more information on this environment variable.
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ You can display general connectivity status from your Unifi (Network) Controller
|
|||||||
|
|
||||||
An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.
|
An optional 'site' parameter can be supplied, if it is not the widget will use the default site for the controller.
|
||||||
|
|
||||||
!!! hint
|
!!! tip
|
||||||
|
|
||||||
If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container to clear the cache.
|
If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container to clear the cache.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ An optional 'site' parameter can be supplied, if it is not the widget will use t
|
|||||||
|
|
||||||
Allowed fields: `["uptime", "wan", "lan", "lan_users", "lan_devices", "wlan", "wlan_users", "wlan_devices"]` (maximum of four). Fields unsupported by the unifi device will not be shown.
|
Allowed fields: `["uptime", "wan", "lan", "lan_users", "lan_devices", "wlan", "wlan_users", "wlan_devices"]` (maximum of four). Fields unsupported by the unifi device will not be shown.
|
||||||
|
|
||||||
!!! hint
|
!!! tip
|
||||||
|
|
||||||
If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache.
|
If you enter e.g. incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache.
|
||||||
|
|
||||||
|
|||||||
24
docs/widgets/services/unifi-drive.md
Normal file
24
docs/widgets/services/unifi-drive.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
title: UniFi Drive
|
||||||
|
description: UniFi Drive Widget Configuration
|
||||||
|
---
|
||||||
|
|
||||||
|
Learn more about [UniFi Drive](https://ui.com/integrations/network-storage).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Displays storage statistics from your UniFi Network Attached Storage (UNAS) device. Requires a local UniFi account with at least read privileges.
|
||||||
|
|
||||||
|
Allowed fields: `["total", "used", "available", "status"]`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
widget:
|
||||||
|
type: unifi_drive
|
||||||
|
url: https://unifi.host.or.ip
|
||||||
|
username: your_username
|
||||||
|
password: your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
If you enter incorrect credentials and receive an "API Error", you may need to recreate the container or restart the service to clear the cache.
|
||||||
@@ -171,6 +171,7 @@ nav:
|
|||||||
- widgets/services/truenas.md
|
- widgets/services/truenas.md
|
||||||
- widgets/services/tubearchivist.md
|
- widgets/services/tubearchivist.md
|
||||||
- widgets/services/unifi-controller.md
|
- widgets/services/unifi-controller.md
|
||||||
|
- widgets/services/unifi-drive.md
|
||||||
- widgets/services/unmanic.md
|
- widgets/services/unmanic.md
|
||||||
- widgets/services/unraid.md
|
- widgets/services/unraid.md
|
||||||
- widgets/services/uptime-kuma.md
|
- widgets/services/uptime-kuma.md
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.12.0",
|
"version": "1.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"minecraftstatuspinger": "^1.2.2",
|
"minecraftstatuspinger": "^1.2.2",
|
||||||
"next": "^16.1.7",
|
"next": "^16.1.7",
|
||||||
|
"next-auth": "^4.24.10",
|
||||||
"next-i18next": "^15.4.3",
|
"next-i18next": "^15.4.3",
|
||||||
"ping": "^0.4.4",
|
"ping": "^0.4.4",
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
|
|||||||
130
pnpm-lock.yaml
generated
130
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: ^16.1.7
|
specifier: ^16.1.7
|
||||||
version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
next-auth:
|
||||||
|
specifier: ^4.24.10
|
||||||
|
version: 4.24.13(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
next-i18next:
|
next-i18next:
|
||||||
specifier: ^15.4.3
|
specifier: ^15.4.3
|
||||||
version: 15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4)
|
version: 15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4)
|
||||||
@@ -829,6 +832,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1':
|
||||||
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1593,11 +1599,11 @@ packages:
|
|||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.13:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
|
||||||
|
|
||||||
brace-expansion@2.0.2:
|
brace-expansion@2.0.3:
|
||||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
@@ -1719,6 +1725,10 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
|
cookie@0.7.2:
|
||||||
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
core-js@3.48.0:
|
core-js@3.48.0:
|
||||||
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
||||||
|
|
||||||
@@ -2594,6 +2604,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@4.15.9:
|
||||||
|
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||||
|
|
||||||
jose@5.10.0:
|
jose@5.10.0:
|
||||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||||
|
|
||||||
@@ -2770,6 +2783,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
|
lru-cache@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
luxon@3.6.1:
|
luxon@3.6.1:
|
||||||
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
|
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2876,6 +2893,20 @@ packages:
|
|||||||
net@1.0.2:
|
net@1.0.2:
|
||||||
resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==}
|
resolution: {integrity: sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==}
|
||||||
|
|
||||||
|
next-auth@4.24.13:
|
||||||
|
resolution: {integrity: sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@auth/core': 0.34.3
|
||||||
|
next: ^12.2.5 || ^13 || ^14 || ^15 || ^16
|
||||||
|
nodemailer: ^7.0.7
|
||||||
|
react: ^17.0.2 || ^18 || ^19
|
||||||
|
react-dom: ^17.0.2 || ^18 || ^19
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@auth/core':
|
||||||
|
optional: true
|
||||||
|
nodemailer:
|
||||||
|
optional: true
|
||||||
|
|
||||||
next-i18next@15.4.3:
|
next-i18next@15.4.3:
|
||||||
resolution: {integrity: sha512-ZRmiz72o1Jvh2ZghCUQX1Ua5F/f2W1/Ila/L1ZeKVuSWiH7J4zfUedfDxNBEhj9lajREC7aoJuPXMFtKi2bdIg==}
|
resolution: {integrity: sha512-ZRmiz72o1Jvh2ZghCUQX1Ua5F/f2W1/Ila/L1ZeKVuSWiH7J4zfUedfDxNBEhj9lajREC7aoJuPXMFtKi2bdIg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2922,10 +2953,17 @@ packages:
|
|||||||
oauth4webapi@3.3.0:
|
oauth4webapi@3.3.0:
|
||||||
resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==}
|
resolution: {integrity: sha512-ZlozhPlFfobzh3hB72gnBFLjXpugl/dljz1fJSRdqaV2r3D5dmi5lg2QWI0LmUYuazmE+b5exsloEv6toUtw9g==}
|
||||||
|
|
||||||
|
oauth@0.9.15:
|
||||||
|
resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
object-hash@2.2.0:
|
||||||
|
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
object-inspect@1.13.4:
|
object-inspect@1.13.4:
|
||||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2954,12 +2992,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
oidc-token-hash@5.2.0:
|
||||||
|
resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==}
|
||||||
|
engines: {node: ^10.13.0 || >=12.0.0}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
one-time@1.0.0:
|
one-time@1.0.0:
|
||||||
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
|
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
|
||||||
|
|
||||||
|
openid-client@5.7.1:
|
||||||
|
resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==}
|
||||||
|
|
||||||
openid-client@6.3.0:
|
openid-client@6.3.0:
|
||||||
resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==}
|
resolution: {integrity: sha512-68LMqb/Whtq214B9c9kCtuniCKQrEqWJRTEoOEZlv2QV5VgqhjySCpBe4RXeU+pj/VNOi7erP/ixxfHtqR7FOw==}
|
||||||
|
|
||||||
@@ -3047,6 +3092,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
preact-render-to-string@5.2.6:
|
||||||
|
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
|
||||||
|
peerDependencies:
|
||||||
|
preact: '>=10'
|
||||||
|
|
||||||
|
preact@10.28.2:
|
||||||
|
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3078,6 +3131,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
pretty-format@3.8.0:
|
||||||
|
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
|
||||||
|
|
||||||
prism-react-renderer@2.4.1:
|
prism-react-renderer@2.4.1:
|
||||||
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3683,6 +3739,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uuid@8.3.2:
|
||||||
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
varint@6.0.0:
|
varint@6.0.0:
|
||||||
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
|
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
|
||||||
|
|
||||||
@@ -3876,6 +3936,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yallist@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
|
||||||
|
|
||||||
yallist@5.0.0:
|
yallist@5.0.0:
|
||||||
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4415,6 +4478,8 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5162,12 +5227,12 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
concat-map: 0.0.1
|
concat-map: 0.0.1
|
||||||
|
|
||||||
brace-expansion@2.0.2:
|
brace-expansion@2.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
@@ -5287,6 +5352,8 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
core-js@3.48.0: {}
|
core-js@3.48.0: {}
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
@@ -6386,6 +6453,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
jose@4.15.9: {}
|
||||||
|
|
||||||
jose@5.10.0: {}
|
jose@5.10.0: {}
|
||||||
|
|
||||||
js-tokens@10.0.0: {}
|
js-tokens@10.0.0: {}
|
||||||
@@ -6549,6 +6618,10 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@11.2.6: {}
|
lru-cache@11.2.6: {}
|
||||||
|
|
||||||
|
lru-cache@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
yallist: 4.0.0
|
||||||
|
|
||||||
luxon@3.6.1: {}
|
luxon@3.6.1: {}
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
@@ -6598,11 +6671,11 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.13
|
||||||
|
|
||||||
minimatch@9.0.5:
|
minimatch@9.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.0.2
|
brace-expansion: 2.0.3
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
@@ -6627,6 +6700,21 @@ snapshots:
|
|||||||
|
|
||||||
net@1.0.2: {}
|
net@1.0.2: {}
|
||||||
|
|
||||||
|
next-auth@4.24.13(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
'@panva/hkdf': 1.2.1
|
||||||
|
cookie: 0.7.2
|
||||||
|
jose: 4.15.9
|
||||||
|
next: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
oauth: 0.9.15
|
||||||
|
openid-client: 5.7.1
|
||||||
|
preact: 10.28.2
|
||||||
|
preact-render-to-string: 5.2.6(preact@10.28.2)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
uuid: 8.3.2
|
||||||
|
|
||||||
next-i18next@15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4):
|
next-i18next@15.4.3(@types/react@19.0.10)(i18next@25.8.0(typescript@5.7.3))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-i18next@15.5.3(i18next@25.8.0(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
@@ -6673,8 +6761,12 @@ snapshots:
|
|||||||
|
|
||||||
oauth4webapi@3.3.0: {}
|
oauth4webapi@3.3.0: {}
|
||||||
|
|
||||||
|
oauth@0.9.15: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
|
object-hash@2.2.0: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
object-keys@1.1.1: {}
|
object-keys@1.1.1: {}
|
||||||
@@ -6714,6 +6806,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
oidc-token-hash@5.2.0: {}
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
@@ -6722,6 +6816,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fn.name: 1.1.0
|
fn.name: 1.1.0
|
||||||
|
|
||||||
|
openid-client@5.7.1:
|
||||||
|
dependencies:
|
||||||
|
jose: 4.15.9
|
||||||
|
lru-cache: 6.0.0
|
||||||
|
object-hash: 2.2.0
|
||||||
|
oidc-token-hash: 5.2.0
|
||||||
|
|
||||||
openid-client@6.3.0:
|
openid-client@6.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
jose: 5.10.0
|
jose: 5.10.0
|
||||||
@@ -6802,6 +6903,13 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
preact-render-to-string@5.2.6(preact@10.28.2):
|
||||||
|
dependencies:
|
||||||
|
preact: 10.28.2
|
||||||
|
pretty-format: 3.8.0
|
||||||
|
|
||||||
|
preact@10.28.2: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier-linter-helpers@1.0.1:
|
prettier-linter-helpers@1.0.1:
|
||||||
@@ -6823,6 +6931,8 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
pretty-format@3.8.0: {}
|
||||||
|
|
||||||
prism-react-renderer@2.4.1(react@19.2.4):
|
prism-react-renderer@2.4.1(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/prismjs': 1.26.5
|
'@types/prismjs': 1.26.5
|
||||||
@@ -7551,6 +7661,8 @@ snapshots:
|
|||||||
|
|
||||||
uuid@10.0.0: {}
|
uuid@10.0.0: {}
|
||||||
|
|
||||||
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
varint@6.0.0: {}
|
varint@6.0.0: {}
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
@@ -7786,6 +7898,8 @@ snapshots:
|
|||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
|
yallist@4.0.0: {}
|
||||||
|
|
||||||
yallist@5.0.0: {}
|
yallist@5.0.0: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Wag asseblief",
|
"wait": "Wag asseblief",
|
||||||
"empty_data": "Substelsel status onbekend"
|
"empty_data": "Substelsel status onbekend"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "حالة النظام الفرعي غير معروفة"
|
"empty_data": "حالة النظام الفرعي غير معروفة"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "استقبال",
|
"rx": "استقبال",
|
||||||
"tx": "ارسال",
|
"tx": "ارسال",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Моля изчакайте",
|
"wait": "Моля изчакайте",
|
||||||
"empty_data": "Неизвестен статус на подсистема"
|
"empty_data": "Неизвестен статус на подсистема"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "ПЧ",
|
"rx": "ПЧ",
|
||||||
"tx": "ИЗ",
|
"tx": "ИЗ",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Si us plau espera",
|
"wait": "Si us plau espera",
|
||||||
"empty_data": "Estat del subsistema desconegut"
|
"empty_data": "Estat del subsistema desconegut"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Rebut",
|
"rx": "Rebut",
|
||||||
"tx": "Transmès",
|
"tx": "Transmès",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"placeholder": "Hledat…"
|
"placeholder": "Hledat…"
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"mem": "Využití paměti",
|
"mem": "Využití paměti",
|
||||||
"total": "Celkem",
|
"total": "Celkem",
|
||||||
"free": "Volné",
|
"free": "Volné",
|
||||||
@@ -66,11 +66,16 @@
|
|||||||
"wait": "Čekejte prosím",
|
"wait": "Čekejte prosím",
|
||||||
"empty_data": "Stav podsystému neznámý"
|
"empty_data": "Stav podsystému neznámý"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Zdravý",
|
||||||
|
"degraded": "Degradováno",
|
||||||
|
"no_data": "Nejsou k dispozici žádná data úložiště"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
"mem": "Využití paměti",
|
"mem": "Využití paměti",
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"running": "Běží",
|
"running": "Běží",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"error": "Chyba",
|
"error": "Chyba",
|
||||||
@@ -232,7 +237,7 @@
|
|||||||
"seed": "Seedované"
|
"seed": "Seedované"
|
||||||
},
|
},
|
||||||
"qnap": {
|
"qnap": {
|
||||||
"cpuUsage": "Zatížení procesoru",
|
"cpuUsage": "Využití procesoru",
|
||||||
"memUsage": "Využití paměti",
|
"memUsage": "Využití paměti",
|
||||||
"systemTempC": "Teplota systému",
|
"systemTempC": "Teplota systému",
|
||||||
"poolUsage": "Využití fondu",
|
"poolUsage": "Využití fondu",
|
||||||
@@ -445,12 +450,12 @@
|
|||||||
},
|
},
|
||||||
"proxmox": {
|
"proxmox": {
|
||||||
"mem": "Využití paměti",
|
"mem": "Využití paměti",
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"lxc": "LXC",
|
"lxc": "LXC",
|
||||||
"vms": "Virtuální Stroje"
|
"vms": "Virtuální Stroje"
|
||||||
},
|
},
|
||||||
"glances": {
|
"glances": {
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"load": "Zatížení",
|
"load": "Zatížení",
|
||||||
"wait": "Čekejte prosím",
|
"wait": "Čekejte prosím",
|
||||||
"temp": "TEPLOTA",
|
"temp": "TEPLOTA",
|
||||||
@@ -635,7 +640,7 @@
|
|||||||
"no_devices": "Žádná přijatá data zařízení"
|
"no_devices": "Žádná přijatá data zařízení"
|
||||||
},
|
},
|
||||||
"mikrotik": {
|
"mikrotik": {
|
||||||
"cpuLoad": "Zatížení procesoru",
|
"cpuLoad": "Využití procesoru",
|
||||||
"memoryUsed": "Využití paměti",
|
"memoryUsed": "Využití paměti",
|
||||||
"uptime": "Doba provozu",
|
"uptime": "Doba provozu",
|
||||||
"numberOfLeases": "Pronájmy"
|
"numberOfLeases": "Pronájmy"
|
||||||
@@ -686,7 +691,7 @@
|
|||||||
"proxmoxbackupserver": {
|
"proxmoxbackupserver": {
|
||||||
"datastore_usage": "Datové úložiště",
|
"datastore_usage": "Datové úložiště",
|
||||||
"failed_tasks_24h": "Neúspěšné úlohy 24h",
|
"failed_tasks_24h": "Neúspěšné úlohy 24h",
|
||||||
"cpu_usage": "Zatížení procesoru",
|
"cpu_usage": "Využití procesoru",
|
||||||
"memory_usage": "Využití paměti"
|
"memory_usage": "Využití paměti"
|
||||||
},
|
},
|
||||||
"immich": {
|
"immich": {
|
||||||
@@ -750,7 +755,7 @@
|
|||||||
"alertstriggered": "Spuštěné výstrahy"
|
"alertstriggered": "Spuštěné výstrahy"
|
||||||
},
|
},
|
||||||
"nextcloud": {
|
"nextcloud": {
|
||||||
"cpuload": "Zatížení procesoru",
|
"cpuload": "Využití procesoru",
|
||||||
"memoryusage": "Využití paměti",
|
"memoryusage": "Využití paměti",
|
||||||
"freespace": "Volný prostor",
|
"freespace": "Volný prostor",
|
||||||
"activeusers": "Aktivní uživatelé",
|
"activeusers": "Aktivní uživatelé",
|
||||||
@@ -873,7 +878,7 @@
|
|||||||
},
|
},
|
||||||
"openwrt": {
|
"openwrt": {
|
||||||
"uptime": "Doba provozu",
|
"uptime": "Doba provozu",
|
||||||
"cpuLoad": "Prům. zatížení procesoru (5m)",
|
"cpuLoad": "Prům. využití procesoru (5m)",
|
||||||
"up": "Běží",
|
"up": "Běží",
|
||||||
"down": "Výpadek",
|
"down": "Výpadek",
|
||||||
"bytesTx": "Přeneseno",
|
"bytesTx": "Přeneseno",
|
||||||
@@ -1037,7 +1042,7 @@
|
|||||||
"pending": "Čekající",
|
"pending": "Čekající",
|
||||||
"status": "Stav",
|
"status": "Stav",
|
||||||
"updated": "Aktualizováno",
|
"updated": "Aktualizováno",
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"memory": "Využití paměti",
|
"memory": "Využití paměti",
|
||||||
"disk": "Disk",
|
"disk": "Disk",
|
||||||
"network": "Síť"
|
"network": "Síť"
|
||||||
@@ -1133,7 +1138,7 @@
|
|||||||
"NO_DATA_DISKS": "Žádné datové disky",
|
"NO_DATA_DISKS": "Žádné datové disky",
|
||||||
"notifications": "Upozornění",
|
"notifications": "Upozornění",
|
||||||
"status": "Stav",
|
"status": "Stav",
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"memoryUsed": "Využití paměti",
|
"memoryUsed": "Využití paměti",
|
||||||
"memoryAvailable": "Volná paměť",
|
"memoryAvailable": "Volná paměť",
|
||||||
"arrayUsed": "Využito pole",
|
"arrayUsed": "Využito pole",
|
||||||
@@ -1164,7 +1169,7 @@
|
|||||||
"dockhand": {
|
"dockhand": {
|
||||||
"running": "Běží",
|
"running": "Běží",
|
||||||
"stopped": "Zastaveno",
|
"stopped": "Zastaveno",
|
||||||
"cpu": "Zatížení procesoru",
|
"cpu": "Využití procesoru",
|
||||||
"memory": "Využití paměti",
|
"memory": "Využití paměti",
|
||||||
"images": "Obrazy",
|
"images": "Obrazy",
|
||||||
"volumes": "Úložiště",
|
"volumes": "Úložiště",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status ukendt"
|
"empty_data": "Subsystem status ukendt"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Bitte warten",
|
"wait": "Bitte warten",
|
||||||
"empty_data": "Subsystem-Status unbekannt"
|
"empty_data": "Subsystem-Status unbekannt"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Gesund",
|
||||||
|
"degraded": "Beeinträchtigt",
|
||||||
|
"no_data": "Keine Speicherdaten verfügbar"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
@@ -115,7 +120,7 @@
|
|||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"episodes": "Episoden",
|
"episodes": "Episoden",
|
||||||
"songs": "Songs"
|
"songs": "Titel"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
@@ -185,10 +190,10 @@
|
|||||||
"plex_connection_error": "Prüfe Plex-Verbindung"
|
"plex_connection_error": "Prüfe Plex-Verbindung"
|
||||||
},
|
},
|
||||||
"tracearr": {
|
"tracearr": {
|
||||||
"no_active": "No Active Streams",
|
"no_active": "Keine aktiven Streams",
|
||||||
"streams": "Streams",
|
"streams": "Streams",
|
||||||
"transcodes": "Transcodes",
|
"transcodes": "Transkodieren",
|
||||||
"directplay": "Direct Play",
|
"directplay": "Direkte Wiedergabe",
|
||||||
"bitrate": "Bitrate"
|
"bitrate": "Bitrate"
|
||||||
},
|
},
|
||||||
"omada": {
|
"omada": {
|
||||||
@@ -290,12 +295,12 @@
|
|||||||
"available": "Verfügbar"
|
"available": "Verfügbar"
|
||||||
},
|
},
|
||||||
"seerr": {
|
"seerr": {
|
||||||
"pending": "Pending",
|
"pending": "Ausstehend",
|
||||||
"approved": "Approved",
|
"approved": "Bestätigt",
|
||||||
"available": "Available",
|
"available": "Verfügbar",
|
||||||
"completed": "Completed",
|
"completed": "Abgeschlossen",
|
||||||
"processing": "Processing",
|
"processing": "Wird verarbeitet",
|
||||||
"issues": "Open Issues"
|
"issues": "Offene Probleme"
|
||||||
},
|
},
|
||||||
"netalertx": {
|
"netalertx": {
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
@@ -615,7 +620,7 @@
|
|||||||
},
|
},
|
||||||
"pangolin": {
|
"pangolin": {
|
||||||
"orgs": "Orgs",
|
"orgs": "Orgs",
|
||||||
"sites": "Sites",
|
"sites": "Seiten",
|
||||||
"resources": "Ressourcen",
|
"resources": "Ressourcen",
|
||||||
"targets": "Ziele",
|
"targets": "Ziele",
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
@@ -719,7 +724,7 @@
|
|||||||
"volumeAvailable": "Verfügbar"
|
"volumeAvailable": "Verfügbar"
|
||||||
},
|
},
|
||||||
"dispatcharr": {
|
"dispatcharr": {
|
||||||
"channels": "Channels",
|
"channels": "Kanäle",
|
||||||
"streams": "Streams"
|
"streams": "Streams"
|
||||||
},
|
},
|
||||||
"mylar": {
|
"mylar": {
|
||||||
@@ -811,10 +816,10 @@
|
|||||||
"series": "Serien"
|
"series": "Serien"
|
||||||
},
|
},
|
||||||
"booklore": {
|
"booklore": {
|
||||||
"libraries": "Libraries",
|
"libraries": "Bibliotheken",
|
||||||
"books": "Bücher",
|
"books": "Bücher",
|
||||||
"reading": "Reading",
|
"reading": "Am Lesen",
|
||||||
"finished": "Finished"
|
"finished": "Fertig"
|
||||||
},
|
},
|
||||||
"jdownloader": {
|
"jdownloader": {
|
||||||
"downloadCount": "Warteschlange",
|
"downloadCount": "Warteschlange",
|
||||||
@@ -1155,11 +1160,11 @@
|
|||||||
"artists": "Künstler"
|
"artists": "Künstler"
|
||||||
},
|
},
|
||||||
"arcane": {
|
"arcane": {
|
||||||
"containers": "Containers",
|
"containers": "Container",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"image_updates": "Image Updates",
|
"image_updates": "Image-Updates",
|
||||||
"images_unused": "Unused",
|
"images_unused": "Ungenutzt",
|
||||||
"environment_required": "Environment ID Required"
|
"environment_required": "Umgebungs-ID erforderlich"
|
||||||
},
|
},
|
||||||
"dockhand": {
|
"dockhand": {
|
||||||
"running": "Wird ausgeführt",
|
"running": "Wird ausgeführt",
|
||||||
@@ -1176,9 +1181,9 @@
|
|||||||
"environment_not_found": "Umgebung nicht gefunden"
|
"environment_not_found": "Umgebung nicht gefunden"
|
||||||
},
|
},
|
||||||
"sparkyfitness": {
|
"sparkyfitness": {
|
||||||
"eaten": "Eaten",
|
"eaten": "",
|
||||||
"burned": "Burned",
|
"burned": "Verbrannt",
|
||||||
"remaining": "Remaining",
|
"remaining": "Verbleibend",
|
||||||
"steps": "Steps"
|
"steps": "Schritte"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Άγνωστη κατάσταση υποσυστήματος"
|
"empty_data": "Άγνωστη κατάσταση υποσυστήματος"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsistemostatuso nekonata"
|
"empty_data": "Subsistemostatuso nekonata"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Espere, por favor",
|
"wait": "Espere, por favor",
|
||||||
"empty_data": "Se desconoce el estado del subsistema"
|
"empty_data": "Se desconoce el estado del subsistema"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Recibido",
|
"rx": "Recibido",
|
||||||
"tx": "Transmitido",
|
"tx": "Transmitido",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Veuillez patienter",
|
"wait": "Veuillez patienter",
|
||||||
"empty_data": "Statut du sous-système inconnu"
|
"empty_data": "Statut du sous-système inconnu"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Rx",
|
"rx": "Rx",
|
||||||
"tx": "Tx",
|
"tx": "Tx",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "נא להמתין",
|
"wait": "נא להמתין",
|
||||||
"empty_data": "מצב תת-מערכת לא ידוע"
|
"empty_data": "מצב תת-מערכת לא ידוע"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Pričekaj",
|
"wait": "Pričekaj",
|
||||||
"empty_data": "Stanje podsustava nepoznato"
|
"empty_data": "Stanje podsustava nepoznato"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Kérjük várjon",
|
"wait": "Kérjük várjon",
|
||||||
"empty_data": "Az alrendszer állapota ismeretlen"
|
"empty_data": "Az alrendszer állapota ismeretlen"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Status subsistem tdk diketahui"
|
"empty_data": "Status subsistem tdk diketahui"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Stato del sottosistema sconosciuto"
|
"empty_data": "Stato del sottosistema sconosciuto"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "お待ちください",
|
"wait": "お待ちください",
|
||||||
"empty_data": "サブシステムの状態は不明"
|
"empty_data": "サブシステムの状態は不明"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "受信済み",
|
"rx": "受信済み",
|
||||||
"tx": "送信済み",
|
"tx": "送信済み",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "잠시만 기다려주세요",
|
"wait": "잠시만 기다려주세요",
|
||||||
"empty_data": "서브시스템 상태 알 수 없음"
|
"empty_data": "서브시스템 상태 알 수 없음"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "수신",
|
"rx": "수신",
|
||||||
"tx": "송신",
|
"tx": "송신",
|
||||||
@@ -108,14 +113,14 @@
|
|||||||
"songs": "음악"
|
"songs": "음악"
|
||||||
},
|
},
|
||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"playing": "Playing",
|
"playing": "재생 중",
|
||||||
"transcoding": "Transcoding",
|
"transcoding": "트랜스코딩 중",
|
||||||
"bitrate": "Bitrate",
|
"bitrate": "비트레이트",
|
||||||
"no_active": "No Active Streams",
|
"no_active": "활성 스트림 없음",
|
||||||
"movies": "Movies",
|
"movies": "영상",
|
||||||
"series": "Series",
|
"series": "시리즈",
|
||||||
"episodes": "Episodes",
|
"episodes": "에피소드",
|
||||||
"songs": "Songs"
|
"songs": "음악"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "오프라인",
|
"offline": "오프라인",
|
||||||
@@ -185,11 +190,11 @@
|
|||||||
"plex_connection_error": "Plex 연결 확인"
|
"plex_connection_error": "Plex 연결 확인"
|
||||||
},
|
},
|
||||||
"tracearr": {
|
"tracearr": {
|
||||||
"no_active": "No Active Streams",
|
"no_active": "활성 스트림 없음",
|
||||||
"streams": "Streams",
|
"streams": "스트림",
|
||||||
"transcodes": "Transcodes",
|
"transcodes": "트랜스코드",
|
||||||
"directplay": "Direct Play",
|
"directplay": "다이렉트 플레이",
|
||||||
"bitrate": "Bitrate"
|
"bitrate": "비트레이트"
|
||||||
},
|
},
|
||||||
"omada": {
|
"omada": {
|
||||||
"connectedAp": "연결된 AP",
|
"connectedAp": "연결된 AP",
|
||||||
@@ -290,12 +295,12 @@
|
|||||||
"available": "이용 가능"
|
"available": "이용 가능"
|
||||||
},
|
},
|
||||||
"seerr": {
|
"seerr": {
|
||||||
"pending": "Pending",
|
"pending": "대기 중",
|
||||||
"approved": "Approved",
|
"approved": "승인됨",
|
||||||
"available": "Available",
|
"available": "사용 가능",
|
||||||
"completed": "Completed",
|
"completed": "완료됨",
|
||||||
"processing": "Processing",
|
"processing": "처리 중",
|
||||||
"issues": "Open Issues"
|
"issues": "열린 이슈"
|
||||||
},
|
},
|
||||||
"netalertx": {
|
"netalertx": {
|
||||||
"total": "전체",
|
"total": "전체",
|
||||||
@@ -546,7 +551,7 @@
|
|||||||
"up": "업",
|
"up": "업",
|
||||||
"pending": "대기 중",
|
"pending": "대기 중",
|
||||||
"down": "다운",
|
"down": "다운",
|
||||||
"ok": "Ok"
|
"ok": "확인"
|
||||||
},
|
},
|
||||||
"healthchecks": {
|
"healthchecks": {
|
||||||
"new": "신규",
|
"new": "신규",
|
||||||
@@ -618,9 +623,9 @@
|
|||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
"targets": "Targets",
|
"targets": "Targets",
|
||||||
"traffic": "Traffic",
|
"traffic": "트래픽",
|
||||||
"in": "In",
|
"in": "수신",
|
||||||
"out": "Out"
|
"out": "송신"
|
||||||
},
|
},
|
||||||
"peanut": {
|
"peanut": {
|
||||||
"battery_charge": "배터리 충전",
|
"battery_charge": "배터리 충전",
|
||||||
@@ -719,8 +724,8 @@
|
|||||||
"volumeAvailable": "사용 가능"
|
"volumeAvailable": "사용 가능"
|
||||||
},
|
},
|
||||||
"dispatcharr": {
|
"dispatcharr": {
|
||||||
"channels": "Channels",
|
"channels": "채널",
|
||||||
"streams": "Streams"
|
"streams": "스트림"
|
||||||
},
|
},
|
||||||
"mylar": {
|
"mylar": {
|
||||||
"series": "시리즈",
|
"series": "시리즈",
|
||||||
@@ -787,7 +792,7 @@
|
|||||||
"gross_percent_today": "오늘",
|
"gross_percent_today": "오늘",
|
||||||
"gross_percent_1y": "1년",
|
"gross_percent_1y": "1년",
|
||||||
"gross_percent_max": "전체 기간",
|
"gross_percent_max": "전체 기간",
|
||||||
"net_worth": "Net Worth"
|
"net_worth": "순자산"
|
||||||
},
|
},
|
||||||
"audiobookshelf": {
|
"audiobookshelf": {
|
||||||
"podcasts": "팟캐스트",
|
"podcasts": "팟캐스트",
|
||||||
@@ -811,10 +816,10 @@
|
|||||||
"series": "시리즈"
|
"series": "시리즈"
|
||||||
},
|
},
|
||||||
"booklore": {
|
"booklore": {
|
||||||
"libraries": "Libraries",
|
"libraries": "라이브러리",
|
||||||
"books": "Books",
|
"books": "책",
|
||||||
"reading": "Reading",
|
"reading": "읽는 중",
|
||||||
"finished": "Finished"
|
"finished": "완료"
|
||||||
},
|
},
|
||||||
"jdownloader": {
|
"jdownloader": {
|
||||||
"downloadCount": "대기열",
|
"downloadCount": "대기열",
|
||||||
@@ -1150,30 +1155,30 @@
|
|||||||
"bytes_added_30": "추가된 용량"
|
"bytes_added_30": "추가된 용량"
|
||||||
},
|
},
|
||||||
"yourspotify": {
|
"yourspotify": {
|
||||||
"songs": "Songs",
|
"songs": "음악",
|
||||||
"time": "Time",
|
"time": "시간",
|
||||||
"artists": "Artists"
|
"artists": "아티스트"
|
||||||
},
|
},
|
||||||
"arcane": {
|
"arcane": {
|
||||||
"containers": "Containers",
|
"containers": "컨테이너",
|
||||||
"images": "Images",
|
"images": "이미지",
|
||||||
"image_updates": "Image Updates",
|
"image_updates": "이미지 업데이트",
|
||||||
"images_unused": "Unused",
|
"images_unused": "미사용",
|
||||||
"environment_required": "Environment ID Required"
|
"environment_required": "환경 ID 필요"
|
||||||
},
|
},
|
||||||
"dockhand": {
|
"dockhand": {
|
||||||
"running": "Running",
|
"running": "실행 중",
|
||||||
"stopped": "Stopped",
|
"stopped": "정지됨",
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"memory": "Memory",
|
"memory": "메모리",
|
||||||
"images": "Images",
|
"images": "이미지",
|
||||||
"volumes": "Volumes",
|
"volumes": "볼륨",
|
||||||
"events_today": "Events Today",
|
"events_today": "오늘의 이벤트",
|
||||||
"pending_updates": "Pending Updates",
|
"pending_updates": "대기 중인 업데이트",
|
||||||
"stacks": "Stacks",
|
"stacks": "스택",
|
||||||
"paused": "Paused",
|
"paused": "일시정지됨",
|
||||||
"total": "Total",
|
"total": "전체",
|
||||||
"environment_not_found": "Environment Not Found"
|
"environment_not_found": "환경 없음"
|
||||||
},
|
},
|
||||||
"sparkyfitness": {
|
"sparkyfitness": {
|
||||||
"eaten": "Eaten",
|
"eaten": "Eaten",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Status subsistem tak diketahui"
|
"empty_data": "Status subsistem tak diketahui"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Even geduld",
|
"wait": "Even geduld",
|
||||||
"empty_data": "Subsysteem status onbekend"
|
"empty_data": "Subsysteem status onbekend"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Ukjent undersystemstatus"
|
"empty_data": "Ukjent undersystemstatus"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Proszę czekać",
|
"wait": "Proszę czekać",
|
||||||
"empty_data": "Status podsystemu nieznany"
|
"empty_data": "Status podsystemu nieznany"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Rx",
|
"rx": "Rx",
|
||||||
"tx": "Tx",
|
"tx": "Tx",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Status de Subsistema Desconhecido"
|
"empty_data": "Status de Subsistema Desconhecido"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Rx",
|
"rx": "Rx",
|
||||||
"tx": "Tx",
|
"tx": "Tx",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"date": "{{value, date}}",
|
"date": "{{value, date}}",
|
||||||
"relativeDate": "{{value, relativeDate}}",
|
"relativeDate": "{{value, relativeDate}}",
|
||||||
"duration": "{{value, duration}}",
|
"duration": "{{value, duration}}",
|
||||||
"months": "M",
|
"months": "mo",
|
||||||
"days": "d",
|
"days": "d",
|
||||||
"hours": "h",
|
"hours": "h",
|
||||||
"minutes": "m",
|
"minutes": "m",
|
||||||
@@ -66,9 +66,14 @@
|
|||||||
"wait": "Por favor, aguarde",
|
"wait": "Por favor, aguarde",
|
||||||
"empty_data": "Status do Subsistema desconhecido"
|
"empty_data": "Status do Subsistema desconhecido"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Rx",
|
"rx": "RX",
|
||||||
"tx": "Tx",
|
"tx": "TX",
|
||||||
"mem": "MEM",
|
"mem": "MEM",
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"running": "Executando",
|
"running": "Executando",
|
||||||
@@ -101,21 +106,21 @@
|
|||||||
"playing": "A reproduzir",
|
"playing": "A reproduzir",
|
||||||
"transcoding": "Transcodificação",
|
"transcoding": "Transcodificação",
|
||||||
"bitrate": "Taxa de bits",
|
"bitrate": "Taxa de bits",
|
||||||
"no_active": "Sem Streams Ativos",
|
"no_active": "Sem Transmissões Ativas",
|
||||||
"movies": "Filmes",
|
"movies": "Filmes",
|
||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
"episodes": "Episódios",
|
"episodes": "Episódios",
|
||||||
"songs": "Canções"
|
"songs": "Canções"
|
||||||
},
|
},
|
||||||
"jellyfin": {
|
"jellyfin": {
|
||||||
"playing": "Playing",
|
"playing": "Jogando",
|
||||||
"transcoding": "Transcoding",
|
"transcoding": "Transcoding",
|
||||||
"bitrate": "Bitrate",
|
"bitrate": "Bitrate",
|
||||||
"no_active": "No Active Streams",
|
"no_active": "No Active Streams",
|
||||||
"movies": "Movies",
|
"movies": "Filmes",
|
||||||
"series": "Series",
|
"series": "Séries",
|
||||||
"episodes": "Episodes",
|
"episodes": "Episódios",
|
||||||
"songs": "Songs"
|
"songs": "Músicas"
|
||||||
},
|
},
|
||||||
"esphome": {
|
"esphome": {
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
@@ -290,12 +295,12 @@
|
|||||||
"available": "Disponível"
|
"available": "Disponível"
|
||||||
},
|
},
|
||||||
"seerr": {
|
"seerr": {
|
||||||
"pending": "Pending",
|
"pending": "Pendente",
|
||||||
"approved": "Approved",
|
"approved": "Aprovado",
|
||||||
"available": "Available",
|
"available": "Disponível",
|
||||||
"completed": "Completed",
|
"completed": "Concluído",
|
||||||
"processing": "Processing",
|
"processing": "Processando",
|
||||||
"issues": "Open Issues"
|
"issues": "Erros pendentes"
|
||||||
},
|
},
|
||||||
"netalertx": {
|
"netalertx": {
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
@@ -616,7 +621,7 @@
|
|||||||
"pangolin": {
|
"pangolin": {
|
||||||
"orgs": "Orgs",
|
"orgs": "Orgs",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
"resources": "Resources",
|
"resources": "Recursos",
|
||||||
"targets": "Targets",
|
"targets": "Targets",
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
"in": "In",
|
"in": "In",
|
||||||
@@ -719,8 +724,8 @@
|
|||||||
"volumeAvailable": "Disponível"
|
"volumeAvailable": "Disponível"
|
||||||
},
|
},
|
||||||
"dispatcharr": {
|
"dispatcharr": {
|
||||||
"channels": "Channels",
|
"channels": "Canais",
|
||||||
"streams": "Streams"
|
"streams": "Transmissões"
|
||||||
},
|
},
|
||||||
"mylar": {
|
"mylar": {
|
||||||
"series": "Séries",
|
"series": "Séries",
|
||||||
@@ -811,10 +816,10 @@
|
|||||||
"series": "Séries"
|
"series": "Séries"
|
||||||
},
|
},
|
||||||
"booklore": {
|
"booklore": {
|
||||||
"libraries": "Libraries",
|
"libraries": "Bibliotecas",
|
||||||
"books": "Books",
|
"books": "Livros",
|
||||||
"reading": "Reading",
|
"reading": "Lendo",
|
||||||
"finished": "Finished"
|
"finished": "Finalizado"
|
||||||
},
|
},
|
||||||
"jdownloader": {
|
"jdownloader": {
|
||||||
"downloadCount": "Fila de espera",
|
"downloadCount": "Fila de espera",
|
||||||
@@ -1155,23 +1160,23 @@
|
|||||||
"artists": "Artistas"
|
"artists": "Artistas"
|
||||||
},
|
},
|
||||||
"arcane": {
|
"arcane": {
|
||||||
"containers": "Containers",
|
"containers": "Recipientes",
|
||||||
"images": "Images",
|
"images": "Imagens",
|
||||||
"image_updates": "Image Updates",
|
"image_updates": "Atualizações de Imagem",
|
||||||
"images_unused": "Unused",
|
"images_unused": "Não utilizado",
|
||||||
"environment_required": "Environment ID Required"
|
"environment_required": "Environment ID Required"
|
||||||
},
|
},
|
||||||
"dockhand": {
|
"dockhand": {
|
||||||
"running": "Running",
|
"running": "Executando",
|
||||||
"stopped": "Stopped",
|
"stopped": "Stopped",
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"memory": "Memory",
|
"memory": "Memória",
|
||||||
"images": "Images",
|
"images": "Imagens",
|
||||||
"volumes": "Volumes",
|
"volumes": "Quantidades",
|
||||||
"events_today": "Events Today",
|
"events_today": "Eventos hoje",
|
||||||
"pending_updates": "Pending Updates",
|
"pending_updates": "Atualizações pendentes",
|
||||||
"stacks": "Stacks",
|
"stacks": "Pilhas",
|
||||||
"paused": "Paused",
|
"paused": "Pausado",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"environment_not_found": "Environment Not Found"
|
"environment_not_found": "Environment Not Found"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Starea subsistemului este necunoscut"
|
"empty_data": "Starea subsistemului este necunoscut"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Пожалуйста, подождите",
|
"wait": "Пожалуйста, подождите",
|
||||||
"empty_data": "Статус подсистемы неизвестен"
|
"empty_data": "Статус подсистемы неизвестен"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Čakajte, prosím",
|
"wait": "Čakajte, prosím",
|
||||||
"empty_data": "Stav podsystému neznámy"
|
"empty_data": "Stav podsystému neznámy"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Prijaté",
|
"rx": "Prijaté",
|
||||||
"tx": "Odoslané",
|
"tx": "Odoslané",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Neznani status podsistema"
|
"empty_data": "Neznani status podsistema"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Молим сачекајте",
|
"wait": "Молим сачекајте",
|
||||||
"empty_data": "Статус подсистема непознат"
|
"empty_data": "Статус подсистема непознат"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "Subsystem status unknown"
|
"empty_data": "Subsystem status unknown"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Lütfen bekleyin",
|
"wait": "Lütfen bekleyin",
|
||||||
"empty_data": "Alt sistem durumu bilinmiyor"
|
"empty_data": "Alt sistem durumu bilinmiyor"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "Gelen Veri",
|
"rx": "Gelen Veri",
|
||||||
"tx": "Giden Veri",
|
"tx": "Giden Veri",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Будь ласка, зачекайте",
|
"wait": "Будь ласка, зачекайте",
|
||||||
"empty_data": "Статус підсистеми невідомий"
|
"empty_data": "Статус підсистеми невідомий"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Vui lòng chờ",
|
"wait": "Vui lòng chờ",
|
||||||
"empty_data": "Trạng thái hệ thống phụ không xác định"
|
"empty_data": "Trạng thái hệ thống phụ không xác định"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "RX",
|
"rx": "RX",
|
||||||
"tx": "TX",
|
"tx": "TX",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "子系統狀態未知"
|
"empty_data": "子系統狀態未知"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "接收",
|
"rx": "接收",
|
||||||
"tx": "發送",
|
"tx": "發送",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "请稍候",
|
"wait": "请稍候",
|
||||||
"empty_data": "子系统状态未知"
|
"empty_data": "子系统状态未知"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "接收",
|
"rx": "接收",
|
||||||
"tx": "发送",
|
"tx": "发送",
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
"wait": "Please wait",
|
"wait": "Please wait",
|
||||||
"empty_data": "子系統狀態未知"
|
"empty_data": "子系統狀態未知"
|
||||||
},
|
},
|
||||||
|
"unifi_drive": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"no_data": "No storage data available"
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"rx": "接收",
|
"rx": "接收",
|
||||||
"tx": "傳送",
|
"tx": "傳送",
|
||||||
|
|||||||
124
src/__tests__/pages/api/auth/[...nextauth].test.js
Normal file
124
src/__tests__/pages/api/auth/[...nextauth].test.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { nextAuthMock } = vi.hoisted(() => ({
|
||||||
|
nextAuthMock: vi.fn((options) => ({ options })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next-auth", () => ({
|
||||||
|
default: nextAuthMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("pages/api/auth/[...nextauth]", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
nextAuthMock.mockClear();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.NEXTAUTH_SECRET;
|
||||||
|
delete process.env.NEXTAUTH_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("configures no providers when auth is disabled", async () => {
|
||||||
|
const mod = await import("pages/api/auth/[...nextauth]");
|
||||||
|
|
||||||
|
expect(nextAuthMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mod.default.options.providers).toEqual([]);
|
||||||
|
expect(mod.default.options.pages?.signIn).toBe("/auth/signin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps HOMEPAGE_AUTH_SECRET and HOMEPAGE_EXTERNAL_URL to NextAuth envs", async () => {
|
||||||
|
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||||
|
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
|
||||||
|
|
||||||
|
const mod = await import("pages/api/auth/[...nextauth]");
|
||||||
|
|
||||||
|
expect(process.env.NEXTAUTH_SECRET).toBe("secret");
|
||||||
|
expect(process.env.NEXTAUTH_URL).toBe("https://homepage.example");
|
||||||
|
expect(mod.default.options.secret).toBe("secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when auth is enabled but no provider settings are present", async () => {
|
||||||
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
|
|
||||||
|
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
|
||||||
|
/Password auth is enabled but required settings are missing/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a password provider when auth is enabled without OIDC config", async () => {
|
||||||
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
|
process.env.HOMEPAGE_AUTH_PASSWORD = "secret";
|
||||||
|
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
|
||||||
|
|
||||||
|
const mod = await import("pages/api/auth/[...nextauth]");
|
||||||
|
const [provider] = mod.default.options.providers;
|
||||||
|
|
||||||
|
expect(provider.id).toBe("credentials");
|
||||||
|
expect(provider.name).toBe("Credentials");
|
||||||
|
expect(provider.type).toBe("credentials");
|
||||||
|
expect(typeof provider.authorize).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an OIDC provider when enabled and maps profile fields", async () => {
|
||||||
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
|
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example/";
|
||||||
|
process.env.HOMEPAGE_OIDC_CLIENT_ID = "client-id";
|
||||||
|
process.env.HOMEPAGE_OIDC_CLIENT_SECRET = "client-secret";
|
||||||
|
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
|
||||||
|
process.env.HOMEPAGE_EXTERNAL_URL = "https://homepage.example";
|
||||||
|
process.env.HOMEPAGE_OIDC_NAME = "My OIDC";
|
||||||
|
process.env.HOMEPAGE_OIDC_SCOPE = "openid email";
|
||||||
|
|
||||||
|
const mod = await import("pages/api/auth/[...nextauth]");
|
||||||
|
const [provider] = mod.default.options.providers;
|
||||||
|
|
||||||
|
expect(provider).toMatchObject({
|
||||||
|
id: "homepage-oidc",
|
||||||
|
name: "My OIDC",
|
||||||
|
type: "oauth",
|
||||||
|
idToken: true,
|
||||||
|
issuer: "https://issuer.example",
|
||||||
|
wellKnown: "https://issuer.example/.well-known/openid-configuration",
|
||||||
|
clientId: "client-id",
|
||||||
|
clientSecret: "client-secret",
|
||||||
|
});
|
||||||
|
expect(provider.authorization.params.scope).toBe("openid email");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.profile({
|
||||||
|
sub: "sub",
|
||||||
|
preferred_username: "user",
|
||||||
|
email: "user@example.com",
|
||||||
|
picture: "https://example.com/p.png",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
id: "sub",
|
||||||
|
name: "user",
|
||||||
|
email: "user@example.com",
|
||||||
|
image: "https://example.com/p.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.profile({
|
||||||
|
id: "id",
|
||||||
|
name: "name",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
id: "id",
|
||||||
|
name: "name",
|
||||||
|
email: null,
|
||||||
|
image: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when only partial OIDC settings are provided", async () => {
|
||||||
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
|
process.env.HOMEPAGE_OIDC_ISSUER = "https://issuer.example";
|
||||||
|
process.env.HOMEPAGE_AUTH_SECRET = "auth-secret";
|
||||||
|
|
||||||
|
await expect(import("pages/api/auth/[...nextauth]")).rejects.toThrow(
|
||||||
|
/OIDC auth is enabled but required settings are missing/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,6 +92,23 @@ describe("pages/api/widgets/glances", () => {
|
|||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to version 3 when version is invalid", async () => {
|
||||||
|
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||||
|
|
||||||
|
httpProxy
|
||||||
|
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ total: 1 }))])
|
||||||
|
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ avg: 2 }))])
|
||||||
|
.mockResolvedValueOnce([200, null, Buffer.from(JSON.stringify({ available: 3 }))]);
|
||||||
|
|
||||||
|
const req = { query: { index: "0", version: "3/../../secret-endpoint" } };
|
||||||
|
const res = createMockRes();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(httpProxy).toHaveBeenCalledWith("http://glances/api/3/cpu", expect.any(Object));
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 400 when glances returns 401", async () => {
|
it("returns 400 when glances returns 401", async () => {
|
||||||
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://glances" });
|
||||||
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);
|
httpProxy.mockResolvedValueOnce([401, null, Buffer.from("nope")]);
|
||||||
|
|||||||
78
src/__tests__/pages/auth/signin.test.jsx
Normal file
78
src/__tests__/pages/auth/signin.test.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { getSettingsMock } = vi.hoisted(() => ({
|
||||||
|
getSettingsMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("utils/config/config", () => ({
|
||||||
|
getSettings: getSettingsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/router", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
query: {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getProviders } from "next-auth/react";
|
||||||
|
import SignInPage, { getServerSideProps } from "pages/auth/signin";
|
||||||
|
|
||||||
|
describe("pages/auth/signin", () => {
|
||||||
|
it("renders an error state when no providers are configured", async () => {
|
||||||
|
render(
|
||||||
|
<SignInPage
|
||||||
|
providers={{}}
|
||||||
|
settings={{
|
||||||
|
theme: "dark",
|
||||||
|
color: "slate",
|
||||||
|
title: "Homepage",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Authentication not configured")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
expect(document.documentElement.classList.contains("scheme-dark")).toBe(true);
|
||||||
|
expect(document.documentElement.classList.contains("theme-slate")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders provider buttons when providers are available", () => {
|
||||||
|
render(
|
||||||
|
<SignInPage
|
||||||
|
providers={{
|
||||||
|
oidc: { id: "oidc", name: "OIDC" },
|
||||||
|
}}
|
||||||
|
settings={{
|
||||||
|
theme: "light",
|
||||||
|
color: "emerald",
|
||||||
|
title: "My Dashboard",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Sign in")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /login via oidc/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getServerSideProps returns providers and settings", async () => {
|
||||||
|
getProviders.mockResolvedValueOnce({ foo: { id: "foo", name: "Foo" } });
|
||||||
|
getSettingsMock.mockReturnValueOnce({ theme: "dark" });
|
||||||
|
|
||||||
|
const res = await getServerSideProps({});
|
||||||
|
|
||||||
|
expect(getProviders).toHaveBeenCalled();
|
||||||
|
expect(getSettingsMock).toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({
|
||||||
|
props: {
|
||||||
|
providers: { foo: { id: "foo", name: "Foo" } },
|
||||||
|
settings: { theme: "dark" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export function middleware(req) {
|
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
||||||
// Check the Host header, if HOMEPAGE_ALLOWED_HOSTS is set
|
const authSecret = process.env.NEXTAUTH_SECRET || process.env.HOMEPAGE_AUTH_SECRET;
|
||||||
const host = req.headers.get("host");
|
let warnedAllowedHosts = false;
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
let allowedHosts = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
|
export async function middleware(req) {
|
||||||
const allowAll = process.env.HOMEPAGE_ALLOWED_HOSTS === "*";
|
if (!warnedAllowedHosts && process.env.HOMEPAGE_ALLOWED_HOSTS) {
|
||||||
if (process.env.HOMEPAGE_ALLOWED_HOSTS) {
|
warnedAllowedHosts = true;
|
||||||
allowedHosts = allowedHosts.concat(process.env.HOMEPAGE_ALLOWED_HOSTS.split(","));
|
console.warn(
|
||||||
}
|
"HOMEPAGE_ALLOWED_HOSTS is deprecated. To secure a publicly accessible homepage, configure authentication instead.",
|
||||||
if (!allowAll && (!host || !allowedHosts.includes(host))) {
|
|
||||||
console.error(
|
|
||||||
`Host validation failed for: ${host}. Hint: Set the HOMEPAGE_ALLOWED_HOSTS environment variable to allow requests from this host / port.`,
|
|
||||||
);
|
);
|
||||||
return NextResponse.json({ error: "Host validation failed. See logs for more details." }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authEnabled) {
|
||||||
|
const token = await getToken({ req, secret: authSecret });
|
||||||
|
if (!token) {
|
||||||
|
const signInUrl = new URL("/auth/signin", req.url);
|
||||||
|
signInUrl.searchParams.set("callbackUrl", "/");
|
||||||
|
return NextResponse.redirect(signInUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: "/api/:path*",
|
// Protect all app and API routes; allow Next.js internals, public assets, auth pages, and NextAuth endpoints.
|
||||||
|
matcher: [
|
||||||
|
"/",
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|manifest.json|sitemap.xml|icons/|api/auth|auth/).*)",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,70 +1,89 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { NextResponse } = vi.hoisted(() => ({
|
const { NextResponse, getToken } = vi.hoisted(() => ({
|
||||||
NextResponse: {
|
NextResponse: {
|
||||||
json: vi.fn((body, init) => ({ type: "json", body, init })),
|
|
||||||
next: vi.fn(() => ({ type: "next" })),
|
next: vi.fn(() => ({ type: "next" })),
|
||||||
|
redirect: vi.fn((url) => ({ type: "redirect", url })),
|
||||||
},
|
},
|
||||||
|
getToken: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next/server", () => ({ NextResponse }));
|
vi.mock("next/server", () => ({ NextResponse }));
|
||||||
|
vi.mock("next-auth/jwt", () => ({ getToken }));
|
||||||
|
|
||||||
import { middleware } from "./middleware";
|
async function loadMiddleware() {
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import("./middleware");
|
||||||
|
return mod.middleware;
|
||||||
|
}
|
||||||
|
|
||||||
function createReq(host) {
|
function createReq(url = "http://localhost:3000/") {
|
||||||
return {
|
return {
|
||||||
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
get: (key) => (key === "host" ? host : null),
|
get: () => null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("middleware", () => {
|
describe("middleware", () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
const originalConsoleError = console.error;
|
const originalConsoleWarn = console.warn;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
console.error = originalConsoleError;
|
console.warn = originalConsoleWarn;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows requests for default localhost hosts", () => {
|
it("allows requests when auth is disabled", async () => {
|
||||||
process.env.PORT = "3000";
|
const middleware = await loadMiddleware();
|
||||||
const res = middleware(createReq("localhost:3000"));
|
const res = await middleware(createReq());
|
||||||
|
|
||||||
expect(NextResponse.next).toHaveBeenCalled();
|
expect(NextResponse.next).toHaveBeenCalled();
|
||||||
expect(res).toEqual({ type: "next" });
|
expect(res).toEqual({ type: "next" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks requests when host is not allowed", () => {
|
it("warns once when HOMEPAGE_ALLOWED_HOSTS is set, but does not block", async () => {
|
||||||
process.env.PORT = "3000";
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com";
|
||||||
|
|
||||||
const res = middleware(createReq("evil.com"));
|
const middleware = await loadMiddleware();
|
||||||
|
const res1 = await middleware(createReq());
|
||||||
expect(errSpy).toHaveBeenCalled();
|
const res2 = await middleware(createReq());
|
||||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
|
||||||
{ error: "Host validation failed. See logs for more details." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
expect(res.type).toBe("json");
|
|
||||||
expect(res.init.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows requests when HOMEPAGE_ALLOWED_HOSTS is '*'", () => {
|
|
||||||
process.env.HOMEPAGE_ALLOWED_HOSTS = "*";
|
|
||||||
const res = middleware(createReq("anything.example"));
|
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(NextResponse.next).toHaveBeenCalled();
|
expect(NextResponse.next).toHaveBeenCalled();
|
||||||
expect(res).toEqual({ type: "next" });
|
expect(res1).toEqual({ type: "next" });
|
||||||
|
expect(res2).toEqual({ type: "next" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows requests when host is included in HOMEPAGE_ALLOWED_HOSTS", () => {
|
it("redirects to signin when auth is enabled and no token is present", async () => {
|
||||||
process.env.PORT = "3000";
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
process.env.HOMEPAGE_ALLOWED_HOSTS = "example.com:3000,other:3000";
|
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||||
|
|
||||||
const res = middleware(createReq("example.com:3000"));
|
getToken.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const middleware = await loadMiddleware();
|
||||||
|
const res = await middleware(createReq("http://localhost:3000/some"));
|
||||||
|
|
||||||
|
expect(getToken).toHaveBeenCalledWith({
|
||||||
|
req: expect.objectContaining({ url: "http://localhost:3000/some" }),
|
||||||
|
secret: "secret",
|
||||||
|
});
|
||||||
|
expect(NextResponse.redirect).toHaveBeenCalled();
|
||||||
|
expect(res.type).toBe("redirect");
|
||||||
|
expect(String(res.url)).toContain("/auth/signin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows requests when auth is enabled and a token is present", async () => {
|
||||||
|
process.env.HOMEPAGE_AUTH_ENABLED = "true";
|
||||||
|
process.env.HOMEPAGE_AUTH_SECRET = "secret";
|
||||||
|
|
||||||
|
getToken.mockResolvedValueOnce({ sub: "user" });
|
||||||
|
|
||||||
|
const middleware = await loadMiddleware();
|
||||||
|
const res = await middleware(createReq("http://localhost:3000/"));
|
||||||
|
|
||||||
expect(NextResponse.next).toHaveBeenCalled();
|
expect(NextResponse.next).toHaveBeenCalled();
|
||||||
expect(res).toEqual({ type: "next" });
|
expect(res).toEqual({ type: "next" });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable react/jsx-props-no-spreading */
|
/* eslint-disable react/jsx-props-no-spreading */
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { appWithTranslation } from "next-i18next";
|
import { appWithTranslation } from "next-i18next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import "styles/globals.css";
|
import "styles/globals.css";
|
||||||
@@ -69,28 +70,30 @@ const tailwindSafelist = [
|
|||||||
|
|
||||||
function MyApp({ Component, pageProps }) {
|
function MyApp({ Component, pageProps }) {
|
||||||
return (
|
return (
|
||||||
<SWRConfig
|
<SessionProvider session={pageProps.session}>
|
||||||
value={{
|
<SWRConfig
|
||||||
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
|
value={{
|
||||||
}}
|
fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()),
|
||||||
>
|
}}
|
||||||
<Head>
|
>
|
||||||
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
|
<Head>
|
||||||
<meta
|
{/* https://nextjs.org/docs/messages/no-document-viewport-meta */}
|
||||||
name="viewport"
|
<meta
|
||||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
name="viewport"
|
||||||
/>
|
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
</Head>
|
/>
|
||||||
<ColorProvider>
|
</Head>
|
||||||
<ThemeProvider>
|
<ColorProvider>
|
||||||
<SettingsProvider>
|
<ThemeProvider>
|
||||||
<TabProvider>
|
<SettingsProvider>
|
||||||
<Component {...pageProps} />
|
<TabProvider>
|
||||||
</TabProvider>
|
<Component {...pageProps} />
|
||||||
</SettingsProvider>
|
</TabProvider>
|
||||||
</ThemeProvider>
|
</SettingsProvider>
|
||||||
</ColorProvider>
|
</ThemeProvider>
|
||||||
</SWRConfig>
|
</ColorProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
src/pages/api/auth/[...nextauth].js
Normal file
114
src/pages/api/auth/[...nextauth].js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
|
||||||
|
const authEnabled = Boolean(process.env.HOMEPAGE_AUTH_ENABLED);
|
||||||
|
const issuer = process.env.HOMEPAGE_OIDC_ISSUER;
|
||||||
|
const clientId = process.env.HOMEPAGE_OIDC_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.HOMEPAGE_OIDC_CLIENT_SECRET;
|
||||||
|
const homepageAuthSecret = process.env.HOMEPAGE_AUTH_SECRET;
|
||||||
|
const homepageExternalUrl = process.env.HOMEPAGE_EXTERNAL_URL;
|
||||||
|
const homepageAuthPassword = process.env.HOMEPAGE_AUTH_PASSWORD;
|
||||||
|
|
||||||
|
// Map HOMEPAGE_* envs to what NextAuth expects
|
||||||
|
if (!process.env.NEXTAUTH_SECRET && homepageAuthSecret) {
|
||||||
|
process.env.NEXTAUTH_SECRET = homepageAuthSecret;
|
||||||
|
}
|
||||||
|
if (!process.env.NEXTAUTH_URL && homepageExternalUrl) {
|
||||||
|
process.env.NEXTAUTH_URL = homepageExternalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultScope = process.env.HOMEPAGE_OIDC_SCOPE || "openid email profile";
|
||||||
|
const cleanedIssuer = issuer ? issuer.replace(/\/+$/, "") : issuer;
|
||||||
|
const hasOidcConfig = Boolean(issuer && clientId && clientSecret);
|
||||||
|
const hasAnyOidcConfig = Boolean(issuer || clientId || clientSecret);
|
||||||
|
|
||||||
|
if (authEnabled) {
|
||||||
|
if (hasOidcConfig) {
|
||||||
|
if (!process.env.NEXTAUTH_SECRET || !process.env.NEXTAUTH_URL) {
|
||||||
|
throw new Error("OIDC auth is enabled but required settings are missing.");
|
||||||
|
}
|
||||||
|
} else if (hasAnyOidcConfig) {
|
||||||
|
throw new Error("OIDC auth is enabled but required settings are missing.");
|
||||||
|
} else if (!homepageAuthPassword || !process.env.NEXTAUTH_SECRET) {
|
||||||
|
throw new Error("Password auth is enabled but required settings are missing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers = [];
|
||||||
|
if (authEnabled) {
|
||||||
|
if (hasOidcConfig) {
|
||||||
|
providers = [
|
||||||
|
{
|
||||||
|
id: "homepage-oidc",
|
||||||
|
name: process.env.HOMEPAGE_OIDC_NAME || "Homepage OIDC",
|
||||||
|
type: "oauth",
|
||||||
|
idToken: true,
|
||||||
|
issuer: cleanedIssuer,
|
||||||
|
wellKnown: `${cleanedIssuer}/.well-known/openid-configuration`,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: defaultScope,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub ?? profile.id ?? profile.user_id ?? profile.uid ?? profile.email,
|
||||||
|
name: profile.name ?? profile.preferred_username ?? profile.nickname ?? profile.email,
|
||||||
|
email: profile.email ?? null,
|
||||||
|
image: profile.picture ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
providers = [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "Password",
|
||||||
|
credentials: {
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
const provided = credentials?.password ?? "";
|
||||||
|
const expected = homepageAuthPassword ?? "";
|
||||||
|
if (!expected || provided.length !== expected.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isMatch = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||||||
|
if (!isMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "homepage",
|
||||||
|
name: "Homepage",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NextAuth({
|
||||||
|
providers,
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
},
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
pages: {
|
||||||
|
signIn: "/auth/signin",
|
||||||
|
},
|
||||||
|
debug: true,
|
||||||
|
logger: {
|
||||||
|
error: (...args) => console.error("[nextauth][error]", ...args),
|
||||||
|
warn: (...args) => console.warn("[nextauth][warn]", ...args),
|
||||||
|
debug: (...args) => console.debug("[nextauth][debug]", ...args),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
signIn: async (message) => console.debug("[nextauth][event][signIn]", message),
|
||||||
|
signOut: async (message) => console.debug("[nextauth][event][signOut]", message),
|
||||||
|
error: async (message) => console.error("[nextauth][event][error]", message),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
|
import { getPrivateWidgetOptions } from "utils/config/widget-helpers";
|
||||||
import createLogger from "utils/logger";
|
import createLogger from "utils/logger";
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import { httpProxy } from "utils/proxy/http";
|
import { httpProxy } from "utils/proxy/http";
|
||||||
|
|
||||||
const logger = createLogger("glances");
|
const logger = createLogger("glances");
|
||||||
@@ -45,7 +46,7 @@ export default async function handler(req, res) {
|
|||||||
const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query;
|
const { index, cputemp: includeCpuTemp, uptime: includeUptime, disk: includeDisks, version } = req.query;
|
||||||
|
|
||||||
const privateWidgetOptions = await getPrivateWidgetOptions("glances", index);
|
const privateWidgetOptions = await getPrivateWidgetOptions("glances", index);
|
||||||
privateWidgetOptions.version = version ?? 3;
|
privateWidgetOptions.version = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu");
|
const cpuData = await retrieveFromGlancesAPI(privateWidgetOptions, "cpu");
|
||||||
|
|||||||
210
src/pages/auth/signin.jsx
Normal file
210
src/pages/auth/signin.jsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { getProviders, signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { BiShieldQuarter } from "react-icons/bi";
|
||||||
|
|
||||||
|
import { getSettings } from "utils/config/config";
|
||||||
|
|
||||||
|
export default function SignIn({ providers, settings }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const theme = settings?.theme || "dark";
|
||||||
|
const color = settings?.color || "slate";
|
||||||
|
const title = settings?.title || "Homepage";
|
||||||
|
const callbackUrl = useMemo(() => {
|
||||||
|
const value = router.query?.callbackUrl;
|
||||||
|
return typeof value === "string" ? value : "/";
|
||||||
|
}, [router.query?.callbackUrl]);
|
||||||
|
const error = router.query?.error;
|
||||||
|
|
||||||
|
let backgroundImage = "";
|
||||||
|
let opacity = settings?.backgroundOpacity ?? 0;
|
||||||
|
let backgroundBlur = false;
|
||||||
|
let backgroundSaturate = false;
|
||||||
|
let backgroundBrightness = false;
|
||||||
|
|
||||||
|
if (settings?.background) {
|
||||||
|
const bg = settings.background;
|
||||||
|
if (typeof bg === "object") {
|
||||||
|
backgroundImage = bg.image || "";
|
||||||
|
if (bg.opacity !== undefined) {
|
||||||
|
opacity = 1 - bg.opacity / 100;
|
||||||
|
}
|
||||||
|
backgroundBlur = bg.blur !== undefined;
|
||||||
|
backgroundSaturate = bg.saturate !== undefined;
|
||||||
|
backgroundBrightness = bg.brightness !== undefined;
|
||||||
|
} else {
|
||||||
|
backgroundImage = bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
html.classList.remove("dark", "scheme-dark", "scheme-light");
|
||||||
|
html.classList.toggle("dark", theme === "dark");
|
||||||
|
html.classList.add(theme === "dark" ? "scheme-dark" : "scheme-light");
|
||||||
|
|
||||||
|
const desiredThemeClass = `theme-${color}`;
|
||||||
|
const themeClassesToRemove = Array.from(html.classList).filter(
|
||||||
|
(cls) => cls.startsWith("theme-") && cls !== desiredThemeClass,
|
||||||
|
);
|
||||||
|
if (themeClassesToRemove.length) {
|
||||||
|
html.classList.remove(...themeClassesToRemove);
|
||||||
|
}
|
||||||
|
if (!html.classList.contains(desiredThemeClass)) {
|
||||||
|
html.classList.add(desiredThemeClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.style.backgroundImage = "";
|
||||||
|
body.style.backgroundColor = "";
|
||||||
|
body.style.backgroundAttachment = "";
|
||||||
|
}, [color, theme]);
|
||||||
|
|
||||||
|
if (!providers || Object.keys(providers).length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{backgroundImage && (
|
||||||
|
<div
|
||||||
|
id="background"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<main
|
||||||
|
className={classNames(
|
||||||
|
"relative flex min-h-screen items-center justify-center px-6 py-12",
|
||||||
|
backgroundBlur &&
|
||||||
|
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
|
||||||
|
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
|
||||||
|
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-xl overflow-hidden rounded-3xl border border-white/40 bg-white/80 p-10 text-center shadow-2xl shadow-black/10 dark:border-white/10 dark:bg-slate-900/70">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-theme-500/20 via-theme-500/5 to-transparent"
|
||||||
|
/>
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-theme-500/15 text-theme-600 dark:text-theme-300">
|
||||||
|
<BiShieldQuarter className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 text-2xl font-semibold text-gray-900 dark:text-slate-100">
|
||||||
|
Authentication not configured
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-gray-600 dark:text-slate-400">OIDC is disabled or misconfigured.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordProvider = providers
|
||||||
|
? Object.values(providers).find((provider) => provider.type === "credentials")
|
||||||
|
: null;
|
||||||
|
const hasPasswordProvider = Boolean(passwordProvider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{backgroundImage && (
|
||||||
|
<div
|
||||||
|
id="background"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgb(var(--bg-color) / ${opacity}), rgb(var(--bg-color) / ${opacity})), url('${backgroundImage}')`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<main className="relative flex min-h-screen items-center justify-center px-6 py-12">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"relative w-full max-w-4xl overflow-hidden rounded-3xl border border-white/50 bg-white/80 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70",
|
||||||
|
backgroundBlur &&
|
||||||
|
`backdrop-blur${settings?.background?.blur?.length ? `-${settings.background.blur}` : ""}`,
|
||||||
|
backgroundSaturate && `backdrop-saturate-${settings.background.saturate}`,
|
||||||
|
backgroundBrightness && `backdrop-brightness-${settings.background.brightness}`,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute -left-24 -top-20 h-64 w-64 rounded-full bg-theme-500/20 blur-3xl" />
|
||||||
|
<div className="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-theme-500/10 blur-3xl" />
|
||||||
|
<div className="grid gap-10 px-8 py-12 md:grid-cols-[1.2fr_1fr] md:px-12">
|
||||||
|
<section className="flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-theme-500/30 bg-theme-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-theme-600 dark:text-theme-300">
|
||||||
|
Login Required
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 text-3xl font-semibold text-gray-900 dark:text-slate-100">{title}</h1>
|
||||||
|
<p className="mt-3 text-sm text-gray-600 dark:text-slate-300">Login to view your dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col justify-center gap-6">
|
||||||
|
<div className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-lg shadow-black/5 dark:border-white/10 dark:bg-slate-900/70">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-slate-100">Sign in</h2>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{hasPasswordProvider && (
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await signIn(passwordProvider?.id ?? "credentials", {
|
||||||
|
redirect: true,
|
||||||
|
callbackUrl,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-gray-900 shadow-sm outline-none ring-0 transition focus:border-theme-500 focus:ring-2 focus:ring-theme-500/30 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">Sign in →</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{!hasPasswordProvider &&
|
||||||
|
Object.values(providers).map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => signIn(provider.id, { callbackUrl })}
|
||||||
|
className="group w-full rounded-xl bg-theme-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-theme-600/20 transition hover:-translate-y-0.5 hover:bg-theme-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-theme-500"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">Login via {provider.name} →</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasPasswordProvider && error && (
|
||||||
|
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-800/60 dark:bg-red-950/40 dark:text-red-200">
|
||||||
|
Invalid password. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(context) {
|
||||||
|
const providers = await getProviders();
|
||||||
|
const settings = getSettings();
|
||||||
|
return {
|
||||||
|
props: { providers, settings },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { getKubeConfig } from "utils/config/kubernetes";
|
|||||||
import * as shvl from "utils/config/shvl";
|
import * as shvl from "utils/config/shvl";
|
||||||
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";
|
||||||
|
|
||||||
const logger = createLogger("service-helpers");
|
const logger = createLogger("service-helpers");
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ export async function servicesFromDocker() {
|
|||||||
}
|
}
|
||||||
let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
|
let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
|
||||||
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
|
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
|
||||||
substitutedVal = parseInt(substitutedVal, 10);
|
substitutedVal = parseVersionForUrl(substitutedVal);
|
||||||
}
|
}
|
||||||
shvl.set(constructedService, value, substitutedVal);
|
shvl.set(constructedService, value, substitutedVal);
|
||||||
}
|
}
|
||||||
@@ -590,7 +591,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
"vikunja",
|
"vikunja",
|
||||||
].includes(type)
|
].includes(type)
|
||||||
) {
|
) {
|
||||||
if (version) widget.version = parseInt(version, 10);
|
widget.version = parseVersionForUrl(version);
|
||||||
}
|
}
|
||||||
if (type === "glances") {
|
if (type === "glances") {
|
||||||
if (metric) widget.metric = metric;
|
if (metric) widget.metric = metric;
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ export function formatApiCall(url, args) {
|
|||||||
return url.replace(find, replace).replace(find, replace);
|
return url.replace(find, replace).replace(find, replace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseVersionForUrl(version, defaultValue = null) {
|
||||||
|
if (version === undefined || version === null || version === "") {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof version === "number") {
|
||||||
|
return Number.isInteger(version) && version >= 0 ? version : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof version === "string" && /^\d+$/.test(version)) {
|
||||||
|
return Number(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
export function getURLSearchParams(widget, endpoint) {
|
export function getURLSearchParams(widget, endpoint) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
group: widget.service_group,
|
group: widget.service_group,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getURLSearchParams,
|
getURLSearchParams,
|
||||||
jsonArrayFilter,
|
jsonArrayFilter,
|
||||||
jsonArrayTransform,
|
jsonArrayTransform,
|
||||||
|
parseVersionForUrl,
|
||||||
sanitizeErrorURL,
|
sanitizeErrorURL,
|
||||||
} from "./api-helpers";
|
} from "./api-helpers";
|
||||||
|
|
||||||
@@ -21,6 +22,20 @@ describe("utils/proxy/api-helpers", () => {
|
|||||||
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
|
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parseVersionForUrl accepts canonical non-negative integers", () => {
|
||||||
|
expect(parseVersionForUrl("3")).toBe(3);
|
||||||
|
expect(parseVersionForUrl(4)).toBe(4);
|
||||||
|
expect(parseVersionForUrl(undefined, 3)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parseVersionForUrl rejects non-canonical values", () => {
|
||||||
|
expect(parseVersionForUrl("3/../../path", 3)).toBe(3);
|
||||||
|
expect(parseVersionForUrl("1e2", 3)).toBe(3);
|
||||||
|
expect(parseVersionForUrl("0x10", 3)).toBe(3);
|
||||||
|
expect(parseVersionForUrl(-1, 3)).toBe(3);
|
||||||
|
expect(parseVersionForUrl(Number.NaN, 3)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
|
it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
|
||||||
const widget = { service_group: "g", service_name: "s", index: "0" };
|
const widget = { service_group: "g", service_name: "s", index: "0" };
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import cache from "memory-cache";
|
|||||||
|
|
||||||
import getServiceWidget from "utils/config/service-helpers";
|
import getServiceWidget from "utils/config/service-helpers";
|
||||||
import createLogger from "utils/logger";
|
import createLogger from "utils/logger";
|
||||||
import { asJson, formatApiCall } from "utils/proxy/api-helpers";
|
import { asJson, formatApiCall, parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import { httpProxy } from "utils/proxy/http";
|
import { httpProxy } from "utils/proxy/http";
|
||||||
import widgets from "widgets/widgets";
|
import widgets from "widgets/widgets";
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ async function getApiInfo(serviceWidget, apiName, serviceName) {
|
|||||||
const json = asJson(data);
|
const json = asJson(data);
|
||||||
if (json?.data?.[apiName]) {
|
if (json?.data?.[apiName]) {
|
||||||
cgiPath = json.data[apiName].path;
|
cgiPath = json.data[apiName].path;
|
||||||
maxVersion = json.data[apiName].maxVersion;
|
maxVersion = parseVersionForUrl(json.data[apiName].maxVersion);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,
|
`Detected ${serviceWidget.type}: apiName '${apiName}', cgiPath '${cgiPath}', and maxVersion ${maxVersion}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ const components = {
|
|||||||
tubearchivist: dynamic(() => import("./tubearchivist/component")),
|
tubearchivist: dynamic(() => import("./tubearchivist/component")),
|
||||||
truenas: dynamic(() => import("./truenas/component")),
|
truenas: dynamic(() => import("./truenas/component")),
|
||||||
unifi: dynamic(() => import("./unifi/component")),
|
unifi: dynamic(() => import("./unifi/component")),
|
||||||
|
unifi_drive: dynamic(() => import("./unifi_drive/component")),
|
||||||
unmanic: dynamic(() => import("./unmanic/component")),
|
unmanic: dynamic(() => import("./unmanic/component")),
|
||||||
unraid: dynamic(() => import("./unraid/component")),
|
unraid: dynamic(() => import("./unraid/component")),
|
||||||
uptimekuma: dynamic(() => import("./uptimekuma/component")),
|
uptimekuma: dynamic(() => import("./uptimekuma/component")),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ async function login(widget) {
|
|||||||
const loginParams = {
|
const loginParams = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: null,
|
body: "{}",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (widget.username && widget.password) {
|
if (widget.username && widget.password) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("widgets/flood/proxy", () => {
|
|||||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://flood/api/stats");
|
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://flood/api/stats");
|
||||||
expect(httpProxy.mock.calls[1][0]).toBe("http://flood/api/auth/authenticate");
|
expect(httpProxy.mock.calls[1][0]).toBe("http://flood/api/auth/authenticate");
|
||||||
expect(httpProxy.mock.calls[1][1].body).toBeNull();
|
expect(httpProxy.mock.calls[1][1].body).toBe("{}");
|
||||||
expect(httpProxy.mock.calls[2][0].toString()).toBe("http://flood/api/stats");
|
expect(httpProxy.mock.calls[2][0].toString()).toBe("http://flood/api/stats");
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toEqual(Buffer.from("data"));
|
expect(res.body).toEqual(Buffer.from("data"));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
@@ -19,11 +20,12 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
const idKey = version === 3 ? "Id" : "id";
|
const idKey = apiVersion === 3 ? "Id" : "id";
|
||||||
const statusKey = version === 3 ? "Status" : "status";
|
const statusKey = apiVersion === 3 ? "Status" : "status";
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(service.widget, `${version}/containers`, {
|
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/containers`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
|
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
|
||||||
@@ -16,14 +17,15 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(service.widget, `${version}/cpu`, {
|
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/cpu`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`);
|
const { data: quicklookData, error: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||||
@@ -16,6 +17,7 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
const [, diskName] = widget.metric.split(":");
|
const [, diskName] = widget.metric.split(":");
|
||||||
|
|
||||||
const [dataPoints, setDataPoints] = useState(
|
const [dataPoints, setDataPoints] = useState(
|
||||||
@@ -23,7 +25,7 @@ export default function Component({ service }) {
|
|||||||
);
|
);
|
||||||
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
|
const [ratePoints, setRatePoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(service.widget, `${version}/diskio`, {
|
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/diskio`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const defaultInterval = 1000;
|
const defaultInterval = 1000;
|
||||||
@@ -11,10 +12,11 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
const [, fsName] = widget.metric.split("fs:");
|
const [, fsName] = widget.metric.split("fs:");
|
||||||
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
|
const diskUnits = widget.diskUnits === "bbytes" ? "common.bbytes" : "common.bytes";
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(widget, `${version}/fs`, {
|
const { data, error } = useWidgetAPI(widget, `${apiVersion}/fs`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||||
@@ -16,11 +17,12 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
const [, gpuName] = widget.metric.split(":");
|
const [, gpuName] = widget.metric.split(":");
|
||||||
|
|
||||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
|
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ a: 0, b: 0 }, 0, pointsLimit));
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(widget, `${version}/gpu`, {
|
const { data, error } = useWidgetAPI(widget, `${apiVersion}/gpu`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "next-i18next";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
function Swap({ quicklookData, className = "" }) {
|
function Swap({ quicklookData, className = "" }) {
|
||||||
@@ -75,12 +76,13 @@ const defaultSystemInterval = 30000; // This data (OS, hostname, distribution) i
|
|||||||
export default function Component({ service }) {
|
export default function Component({ service }) {
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${version}/quicklook`, {
|
const { data: quicklookData, errorL: quicklookError } = useWidgetAPI(service.widget, `${apiVersion}/quicklook`, {
|
||||||
refreshInterval,
|
refreshInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${version}/system`, {
|
const { data: systemData, errorL: systemError } = useWidgetAPI(service.widget, `${apiVersion}/system`, {
|
||||||
refreshInterval: defaultSystemInterval,
|
refreshInterval: defaultSystemInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||||
@@ -17,10 +18,11 @@ export default function Component({ service }) {
|
|||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart } = widget;
|
const { chart } = widget;
|
||||||
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(service.widget, `${version}/mem`, {
|
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/mem`, {
|
||||||
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
|
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
const ChartDual = dynamic(() => import("../components/chart_dual"), { ssr: false });
|
||||||
@@ -17,15 +18,16 @@ export default function Component({ service }) {
|
|||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, metric } = widget;
|
const { chart, metric } = widget;
|
||||||
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
const { refreshInterval = defaultInterval(chart), pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
const rxKey = version === 3 ? "rx" : "bytes_recv";
|
const rxKey = apiVersion === 3 ? "rx" : "bytes_recv";
|
||||||
const txKey = version === 3 ? "tx" : "bytes_sent";
|
const txKey = apiVersion === 3 ? "tx" : "bytes_sent";
|
||||||
|
|
||||||
const [, interfaceName] = metric.split(":");
|
const [, interfaceName] = metric.split(":");
|
||||||
|
|
||||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(widget, `${version}/network`, {
|
const { data, error } = useWidgetAPI(widget, `${apiVersion}/network`, {
|
||||||
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
|
refreshInterval: Math.max(defaultInterval(chart), refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "next-i18next";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
@@ -22,10 +23,11 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
|
|
||||||
const memoryInfoKey = version === 3 ? 0 : "rss";
|
const memoryInfoKey = apiVersion === 3 ? 0 : "rss";
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(service.widget, `${version}/processlist`, {
|
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/processlist`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Block from "../components/block";
|
import Block from "../components/block";
|
||||||
import Container from "../components/container";
|
import Container from "../components/container";
|
||||||
|
|
||||||
|
import { parseVersionForUrl } from "utils/proxy/api-helpers";
|
||||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
|
const Chart = dynamic(() => import("../components/chart"), { ssr: false });
|
||||||
@@ -16,11 +17,12 @@ export default function Component({ service }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { widget } = service;
|
const { widget } = service;
|
||||||
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
const { chart, refreshInterval = defaultInterval, pointsLimit = defaultPointsLimit, version = 3 } = widget;
|
||||||
|
const apiVersion = parseVersionForUrl(version, 3);
|
||||||
const [, sensorName] = widget.metric.split(":");
|
const [, sensorName] = widget.metric.split(":");
|
||||||
|
|
||||||
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
const [dataPoints, setDataPoints] = useState(new Array(pointsLimit).fill({ value: 0 }, 0, pointsLimit));
|
||||||
|
|
||||||
const { data, error } = useWidgetAPI(service.widget, `${version}/sensors`, {
|
const { data, error } = useWidgetAPI(service.widget, `${apiVersion}/sensors`, {
|
||||||
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
refreshInterval: Math.max(defaultInterval, refreshInterval),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
|||||||
const widget = {
|
const widget = {
|
||||||
api: "{url}/api/{endpoint}",
|
api: "{url}/api/{endpoint}",
|
||||||
proxyHandler: credentialedProxyHandler,
|
proxyHandler: credentialedProxyHandler,
|
||||||
allowedEndpoints: /\d\/quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers/,
|
allowedEndpoints: /^\d+\/(quicklook|diskio|cpu|fs|gpu|system|mem|network|processlist|sensors|containers)$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default widget;
|
export default widget;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ describe("glances widget config", () => {
|
|||||||
it("exports a valid widget config", () => {
|
it("exports a valid widget config", () => {
|
||||||
expectWidgetConfigShape(widget);
|
expectWidgetConfigShape(widget);
|
||||||
expect(widget.allowedEndpoints?.test("3/quicklook")).toBe(true);
|
expect(widget.allowedEndpoints?.test("3/quicklook")).toBe(true);
|
||||||
|
expect(widget.allowedEndpoints?.test("12/cpu")).toBe(true);
|
||||||
expect(widget.allowedEndpoints?.test("unknown")).toBe(false);
|
expect(widget.allowedEndpoints?.test("unknown")).toBe(false);
|
||||||
|
expect(widget.allowedEndpoints?.test("xxcpuyy")).toBe(false);
|
||||||
|
expect(widget.allowedEndpoints?.test("3/cpu/extra")).toBe(false);
|
||||||
|
expect(widget.allowedEndpoints?.test("membrane")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
58
src/widgets/unifi_drive/component.jsx
Normal file
58
src/widgets/unifi_drive/component.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Block from "components/services/widget/block";
|
||||||
|
import Container from "components/services/widget/container";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
|
||||||
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
|
export default function Component({ service }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { widget } = service;
|
||||||
|
|
||||||
|
const { data: storageData, error: storageError } = useWidgetAPI(widget, "storage");
|
||||||
|
|
||||||
|
if (storageError) {
|
||||||
|
return <Container service={service} error={storageError} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storageData) {
|
||||||
|
return (
|
||||||
|
<Container service={service}>
|
||||||
|
<Block field="unifi_drive.total" label="resources.total" />
|
||||||
|
<Block field="unifi_drive.used" label="resources.used" />
|
||||||
|
<Block field="unifi_drive.available" label="resources.free" />
|
||||||
|
<Block field="unifi_drive.status" label="widget.status" />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: storage } = storageData;
|
||||||
|
|
||||||
|
if (!storage) {
|
||||||
|
return (
|
||||||
|
<Container service={service}>
|
||||||
|
<Block value={t("unifi_drive.no_data")} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalQuota, usage, status } = storage;
|
||||||
|
const totalBytes = totalQuota ?? 0;
|
||||||
|
const usedBytes = (usage?.system || 0) + (usage?.myDrives || 0) + (usage?.sharedDrives || 0);
|
||||||
|
const availableBytes = Math.max(0, totalBytes - usedBytes);
|
||||||
|
let statusValue = status;
|
||||||
|
if (status === "healthy") statusValue = t("unifi_drive.healthy");
|
||||||
|
else if (status === "degraded") statusValue = t("unifi_drive.degraded");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container service={service}>
|
||||||
|
<Block field="unifi_drive.total" label="resources.total" value={t("common.bytes", { value: totalBytes })} />
|
||||||
|
<Block field="unifi_drive.used" label="resources.used" value={t("common.bytes", { value: usedBytes })} />
|
||||||
|
<Block
|
||||||
|
field="unifi_drive.available"
|
||||||
|
label="resources.free"
|
||||||
|
value={t("common.bytes", { value: availableBytes })}
|
||||||
|
/>
|
||||||
|
<Block field="unifi_drive.status" label="widget.status" value={statusValue} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/widgets/unifi_drive/component.test.jsx
Normal file
92
src/widgets/unifi_drive/component.test.jsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||||
|
import { expectBlockValue } from "test-utils/widget-assertions";
|
||||||
|
|
||||||
|
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||||
|
|
||||||
|
import Component from "./component";
|
||||||
|
|
||||||
|
describe("widgets/unifi_drive/component", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders placeholders while loading", () => {
|
||||||
|
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||||
|
|
||||||
|
const service = { widget: { type: "unifi_drive" } };
|
||||||
|
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||||
|
expect(screen.getByText("resources.total")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("resources.used")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("resources.free")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("widget.status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders error when API fails", () => {
|
||||||
|
useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("fail") });
|
||||||
|
|
||||||
|
const service = { widget: { type: "unifi_drive" } };
|
||||||
|
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||||
|
|
||||||
|
expect(screen.getAllByText("widget.api_error", { exact: false }).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no_data when storage data is missing", () => {
|
||||||
|
useWidgetAPI.mockReturnValue({ data: { data: null }, error: undefined });
|
||||||
|
|
||||||
|
const service = { widget: { type: "unifi_drive" } };
|
||||||
|
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||||
|
|
||||||
|
expect(screen.getByText("unifi_drive.no_data")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders storage statistics when data is loaded", () => {
|
||||||
|
useWidgetAPI.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
totalQuota: 1000000000000,
|
||||||
|
usage: { system: 100000000000, myDrives: 200000000000, sharedDrives: 50000000000 },
|
||||||
|
status: "healthy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = { widget: { type: "unifi_drive" } };
|
||||||
|
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||||
|
expectBlockValue(container, "resources.total", 1000000000000);
|
||||||
|
expectBlockValue(container, "resources.used", 350000000000);
|
||||||
|
expectBlockValue(container, "resources.free", 650000000000);
|
||||||
|
expectBlockValue(container, "widget.status", "unifi_drive.healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders degraded status", () => {
|
||||||
|
useWidgetAPI.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
totalQuota: 100,
|
||||||
|
usage: { system: 10, myDrives: 20, sharedDrives: 5 },
|
||||||
|
status: "degraded",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = { widget: { type: "unifi_drive" } };
|
||||||
|
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
|
||||||
|
expectBlockValue(container, "widget.status", "unifi_drive.degraded");
|
||||||
|
expectBlockValue(container, "resources.free", 65);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/widgets/unifi_drive/proxy.js
Normal file
36
src/widgets/unifi_drive/proxy.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import getServiceWidget from "utils/config/service-helpers";
|
||||||
|
import createUnifiProxyHandler from "utils/proxy/handlers/unifi";
|
||||||
|
import { httpProxy } from "utils/proxy/http";
|
||||||
|
|
||||||
|
const drivePrefix = "/proxy/drive";
|
||||||
|
|
||||||
|
async function getWidget(req, logger) {
|
||||||
|
const { group, service, index } = req.query;
|
||||||
|
if (!group || !service) return null;
|
||||||
|
|
||||||
|
const widget = await getServiceWidget(group, service, index);
|
||||||
|
if (!widget) {
|
||||||
|
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRequestContext({ cachedPrefix, widget }) {
|
||||||
|
if (cachedPrefix !== null) {
|
||||||
|
return { prefix: cachedPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, , , responseHeaders] = await httpProxy(widget.url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix: drivePrefix,
|
||||||
|
csrfToken: responseHeaders?.["x-csrf-token"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createUnifiProxyHandler({
|
||||||
|
proxyName: "unifiDriveProxyHandler",
|
||||||
|
resolveWidget: getWidget,
|
||||||
|
resolveRequestContext,
|
||||||
|
});
|
||||||
82
src/widgets/unifi_drive/proxy.test.js
Normal file
82
src/widgets/unifi_drive/proxy.test.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import createMockRes from "test-utils/create-mock-res";
|
||||||
|
|
||||||
|
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||||
|
const store = new Map();
|
||||||
|
return {
|
||||||
|
httpProxy: vi.fn(),
|
||||||
|
getServiceWidget: vi.fn(),
|
||||||
|
cache: {
|
||||||
|
get: vi.fn((k) => (store.has(k) ? store.get(k) : null)),
|
||||||
|
put: vi.fn((k, v) => store.set(k, v)),
|
||||||
|
del: vi.fn((k) => store.delete(k)),
|
||||||
|
_reset: () => store.clear(),
|
||||||
|
},
|
||||||
|
logger: { debug: vi.fn(), error: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("memory-cache", () => ({ default: cache, ...cache }));
|
||||||
|
vi.mock("utils/logger", () => ({ default: () => logger }));
|
||||||
|
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
|
||||||
|
vi.mock("utils/proxy/http", () => ({ httpProxy }));
|
||||||
|
vi.mock("widgets/widgets", () => ({
|
||||||
|
default: { unifi_drive: { api: "{url}{prefix}/api/{endpoint}" } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import unifiDriveProxyHandler from "./proxy";
|
||||||
|
|
||||||
|
const widgetConfig = { type: "unifi_drive", url: "http://unifi", username: "u", password: "p" };
|
||||||
|
|
||||||
|
describe("widgets/unifi_drive/proxy", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cache._reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when widget config is missing", async () => {
|
||||||
|
getServiceWidget.mockResolvedValue(null);
|
||||||
|
const res = createMockRes();
|
||||||
|
await unifiDriveProxyHandler(
|
||||||
|
{ query: { group: "g", service: "s", endpoint: "v1/systems/storage?type=detail" } },
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when widget type has no API config", async () => {
|
||||||
|
getServiceWidget.mockResolvedValue({ ...widgetConfig, type: "unknown" });
|
||||||
|
const res = createMockRes();
|
||||||
|
await unifiDriveProxyHandler({ query: { group: "g", service: "s", endpoint: "storage" } }, res);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses /proxy/drive prefix and returns data on success", async () => {
|
||||||
|
getServiceWidget.mockResolvedValue({ ...widgetConfig });
|
||||||
|
httpProxy
|
||||||
|
.mockResolvedValueOnce([200, "text/html", Buffer.from(""), {}])
|
||||||
|
.mockResolvedValueOnce([200, "application/json", Buffer.from('{"data":{}}'), {}]);
|
||||||
|
|
||||||
|
const res = createMockRes();
|
||||||
|
await unifiDriveProxyHandler({ query: { group: "g", service: "s", endpoint: "storage" } }, res);
|
||||||
|
|
||||||
|
expect(httpProxy.mock.calls[0][0]).toBe("http://unifi");
|
||||||
|
expect(httpProxy.mock.calls[1][0].toString()).toContain("/proxy/drive/api/");
|
||||||
|
expect(cache.put).toHaveBeenCalledWith("unifiDriveProxyHandler__prefix.s", "/proxy/drive");
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips prefix detection when cached", async () => {
|
||||||
|
getServiceWidget.mockResolvedValue({ ...widgetConfig });
|
||||||
|
cache.put("unifiDriveProxyHandler__prefix.s", "/proxy/drive");
|
||||||
|
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from('{"data":{}}'), {}]);
|
||||||
|
|
||||||
|
const res = createMockRes();
|
||||||
|
await unifiDriveProxyHandler({ query: { group: "g", service: "s", endpoint: "storage" } }, res);
|
||||||
|
|
||||||
|
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(httpProxy.mock.calls[0][0].toString()).toContain("/proxy/drive/api/");
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/widgets/unifi_drive/widget.js
Normal file
14
src/widgets/unifi_drive/widget.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import unifiDriveProxyHandler from "./proxy";
|
||||||
|
|
||||||
|
const widget = {
|
||||||
|
api: "{url}{prefix}/api/{endpoint}",
|
||||||
|
proxyHandler: unifiDriveProxyHandler,
|
||||||
|
|
||||||
|
mappings: {
|
||||||
|
storage: {
|
||||||
|
endpoint: "v1/systems/storage?type=detail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default widget;
|
||||||
11
src/widgets/unifi_drive/widget.test.js
Normal file
11
src/widgets/unifi_drive/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||||
|
|
||||||
|
import widget from "./widget";
|
||||||
|
|
||||||
|
describe("unifi_drive widget config", () => {
|
||||||
|
it("exports a valid widget config", () => {
|
||||||
|
expectWidgetConfigShape(widget);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,6 +137,7 @@ import trilium from "./trilium/widget";
|
|||||||
import truenas from "./truenas/widget";
|
import truenas from "./truenas/widget";
|
||||||
import tubearchivist from "./tubearchivist/widget";
|
import tubearchivist from "./tubearchivist/widget";
|
||||||
import unifi from "./unifi/widget";
|
import unifi from "./unifi/widget";
|
||||||
|
import unifi_drive from "./unifi_drive/widget";
|
||||||
import unmanic from "./unmanic/widget";
|
import unmanic from "./unmanic/widget";
|
||||||
import unraid from "./unraid/widget";
|
import unraid from "./unraid/widget";
|
||||||
import uptimekuma from "./uptimekuma/widget";
|
import uptimekuma from "./uptimekuma/widget";
|
||||||
@@ -296,6 +297,7 @@ const widgets = {
|
|||||||
truenas,
|
truenas,
|
||||||
unifi,
|
unifi,
|
||||||
unifi_console: unifi,
|
unifi_console: unifi,
|
||||||
|
unifi_drive,
|
||||||
unmanic,
|
unmanic,
|
||||||
unraid,
|
unraid,
|
||||||
uptimekuma,
|
uptimekuma,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ afterEach(() => {
|
|||||||
if (typeof document !== "undefined") cleanup();
|
if (typeof document !== "undefined") cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Avoid NextAuth client-side fetches during unit tests.
|
||||||
|
vi.mock("next-auth/react", () => ({
|
||||||
|
SessionProvider: ({ children }) => children ?? null,
|
||||||
|
getProviders: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
// implement a couple of common formatters mocked in next-i18next
|
// implement a couple of common formatters mocked in next-i18next
|
||||||
vi.mock("next-i18next", () => ({
|
vi.mock("next-i18next", () => ({
|
||||||
// Keep app/page components importable in unit tests.
|
// Keep app/page components importable in unit tests.
|
||||||
|
|||||||
Reference in New Issue
Block a user