Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cecd58626 | ||
|
|
e4b061920c | ||
|
|
65ba866239 | ||
|
|
260b167134 | ||
|
|
7a4de03c0e | ||
|
|
68f8e8146b | ||
|
|
1297e6c2ac | ||
|
|
31288dcbfe | ||
|
|
fc2c251557 | ||
|
|
d067261171 | ||
|
|
ae2f05c834 | ||
|
|
c7fa7cdb50 | ||
|
|
0c28224e82 | ||
|
|
4e6272dbe2 | ||
|
|
f5bc090d36 | ||
|
|
f23e06dff1 | ||
|
|
24188ce51f | ||
|
|
e31f60fdec | ||
|
|
6bb547f3c5 | ||
|
|
b07cc14d0e | ||
|
|
0dc93c1a02 | ||
|
|
00661c9b3d | ||
|
|
2e8fddd29f | ||
|
|
302d5a0d04 | ||
|
|
a9ed95856a | ||
|
|
4d42eb65e4 | ||
|
|
82858ecd75 | ||
|
|
0e365ed53c | ||
|
|
a55dc98487 | ||
|
|
5b91687912 | ||
|
|
a3df31ff05 | ||
|
|
c5c9daf5a5 | ||
|
|
ab0e878fbf | ||
|
|
885be1b9a1 | ||
|
|
0f8a86fdde | ||
|
|
59c084bc57 | ||
|
|
f6cf262bab | ||
|
|
ba860aacaa | ||
|
|
161dafbdb8 | ||
|
|
a382c75968 | ||
|
|
529942e4da | ||
|
|
e20053b2c1 | ||
|
|
134106b82c | ||
|
|
7635031fdf | ||
|
|
b663dae409 | ||
|
|
2c2bdb6b76 | ||
|
|
dee9cd7aac | ||
|
|
7c222bf5e8 | ||
|
|
f914cc26be | ||
|
|
6f47a05cc2 | ||
|
|
d8d5894b28 | ||
|
|
9252fdc139 | ||
|
|
f19033e0d5 | ||
|
|
a5ec880eec | ||
|
|
b588672709 | ||
|
|
ceed5c6751 | ||
|
|
f7ede23dc3 | ||
|
|
1b50249ea9 | ||
|
|
1d57519005 | ||
|
|
dd2eb3aa66 | ||
|
|
46ae756d15 | ||
|
|
8b63799f1b | ||
|
|
60a69e31ef | ||
|
|
57ef0af4a2 | ||
|
|
597b5fbc8d | ||
|
|
5412577519 |
No files matched your search
+8
-1
@@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# ENABLE_KUBERNETES=true
|
||||
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
@@ -33,6 +33,8 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.69.2
|
||||
- 1.69.0
|
||||
- 1.68.0
|
||||
- 1.67.0
|
||||
- 1.66.1
|
||||
|
||||
@@ -11,7 +11,7 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
uses: actions/create-github-app-token@v3
|
||||
|
||||
@@ -25,13 +25,13 @@ env:
|
||||
|
||||
jobs:
|
||||
lint-format-autofix:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Set up Docker Buildx
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup
|
||||
@@ -163,65 +163,3 @@ jobs:
|
||||
run: cp .env.example .env
|
||||
- name: Build
|
||||
run: pnpm turbo build --affected
|
||||
|
||||
memory-regression:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: homarr-memory-test
|
||||
network: host
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
- name: Start container and measure memory
|
||||
run: |
|
||||
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
|
||||
# Wait for container to be healthy (up to 60s)
|
||||
healthy=false
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
|
||||
echo "Container healthy after $((i * 2))s"
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$healthy" != "true" ]; then
|
||||
echo "ERROR: Container never became healthy"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
# Warm up: hit the dashboard page a few times
|
||||
for i in $(seq 1 5); do
|
||||
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
|
||||
sleep 2
|
||||
done
|
||||
# Measure RSS after warmup
|
||||
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
|
||||
echo "Raw memory: $MEM_KB"
|
||||
# Convert to numeric MB (handles GiB and MiB suffixes)
|
||||
MEM_NUM=$(echo "$MEM_KB" | awk '{
|
||||
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
|
||||
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
|
||||
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
|
||||
else { print $1 / 1048576 }
|
||||
}')
|
||||
echo "Memory usage: ${MEM_NUM} MB"
|
||||
MAX_MB=700
|
||||
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
|
||||
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
|
||||
docker logs homarr-mem-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Upload Crowdin translations
|
||||
uses: crowdin/github-action@v2
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get Next Version
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }}
|
||||
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Build docs
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get Next Version
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Validate prebuilt dependencies for amd64
|
||||
id: validate-amd64
|
||||
uses: ./.github/actions/prebuilt-debian
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Validate prebuilt dependencies for arm64
|
||||
id: validate-arm64
|
||||
uses: ./.github/actions/prebuilt-debian
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: |
|
||||
npx --yes --package renovate -- \
|
||||
renovate-config-validator --strict .github/renovate.json5
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ steps.obtainToken.outputs.token }}
|
||||
ref: dev
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
permission-contents: write # required to commit to branch
|
||||
permission-pull-requests: write # required to create pr & enable automerge
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
- name: Setup
|
||||
|
||||
@@ -77,3 +77,4 @@ skills-lock.json
|
||||
# docusaurus
|
||||
apps/docs/.docusaurus
|
||||
apps/docs/build
|
||||
.pnpm-store
|
||||
@@ -1,3 +1,48 @@
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **layout:** raise AppShellHeader z-index above Indicator dots ([687edb9](https://github.com/homarr-labs/homarr/commit/687edb9226307cb83986097abb24fe7f672e1778)), closes [#6194](https://github.com/homarr-labs/homarr/issues/6194)
|
||||
|
||||
## [1.69.1](https://github.com/homarr-labs/homarr/compare/v1.69.0...v1.69.1) (2026-07-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* unblock query cache mutations in demo mode and add cache buster ([#6176](https://github.com/homarr-labs/homarr/issues/6176)) ([a382c75](https://github.com/homarr-labs/homarr/commit/a382c75968e73bafff55b5890b1e95f0779b50d3))
|
||||
|
||||
## [1.69.0](https://github.com/homarr-labs/homarr/compare/v1.68.1...v1.69.0) (2026-07-03)
|
||||
|
||||
### Features
|
||||
|
||||
* add per-widget secret storage for release provider authentication ([77884a3](https://github.com/homarr-labs/homarr/commit/77884a31794e12a6f7bc59d96c8f61aa429ca68d))
|
||||
* **config:** enable pnpm global virtual store for worktree support ([#6098](https://github.com/homarr-labs/homarr/issues/6098)) ([3864727](https://github.com/homarr-labs/homarr/commit/3864727502a64052c4e3838468094b30bf04a133))
|
||||
* **docker-widget:** add per-column visibility toggle ([#6093](https://github.com/homarr-labs/homarr/issues/6093)) ([0765262](https://github.com/homarr-labs/homarr/commit/076526293d13379004461633d7ba4c2e83bd5ffe))
|
||||
* **docs:** addd in documentation for archiveteam warrior ([f66469d](https://github.com/homarr-labs/homarr/commit/f66469d79797a2da214588f4e8253b03633c59a5))
|
||||
* simplify caching — TanStack Query as single source of truth ([#6075](https://github.com/homarr-labs/homarr/issues/6075)) ([f19033e](https://github.com/homarr-labs/homarr/commit/f19033e0d53d248aed678737f3d04d5d8c7815b5))
|
||||
* **widgets:** right-click context menu with auto-generated toggle actions ([#6126](https://github.com/homarr-labs/homarr/issues/6126)) ([7e58858](https://github.com/homarr-labs/homarr/commit/7e58858353ae866efd657270d4bb16d2c7d3e551))
|
||||
* **widgets:** show calendar events on hover with pin on click ([#2637](https://github.com/homarr-labs/homarr/issues/2637)) ([#6081](https://github.com/homarr-labs/homarr/issues/6081)) ([db3d588](https://github.com/homarr-labs/homarr/commit/db3d58821e4de8d46b110125d0fa36919765248f))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add id to repository schema and handle missing ids gracefully ([8747d5f](https://github.com/homarr-labs/homarr/commit/8747d5f93ee9a295aad3c7035e8c6c7080bf5ee6))
|
||||
* address PR review - atomic upsert, MCP metadata, stale tokens, save UX ([757d598](https://github.com/homarr-labs/homarr/commit/757d59854e5ec82d91c053b8bc3d7eaf2e70efc4))
|
||||
* address PR review findings for widget-only integration refactor ([c892731](https://github.com/homarr-labs/homarr/commit/c892731479b47fab6218bd1f52fa6eccbdaa371e))
|
||||
* address review - paginate GH releases, GitLab tag_name, docs, test ([e687bc8](https://github.com/homarr-labs/homarr/commit/e687bc8c9277797acaa164a09c74b3808a2cb346))
|
||||
* apply version filter to LinuxServer.io provider ([34d976e](https://github.com/homarr-labs/homarr/commit/34d976eeea09e396334ce8fce5c492c07e7dd32b))
|
||||
* catch individual repo fetch failures to prevent batch crashes ([d893515](https://github.com/homarr-labs/homarr/commit/d893515b932608ea08c9c6e90b83f340e40f6f8e))
|
||||
* compile versionRegex once and add fetch timeouts ([64c7512](https://github.com/homarr-labs/homarr/commit/64c75122833981a49e9c8bf1789d501faff6d62d))
|
||||
* **deps:** update dependency postcss to ^8.5.16 ([#6169](https://github.com/homarr-labs/homarr/issues/6169)) ([9252fdc](https://github.com/homarr-labs/homarr/commit/9252fdc13971bd982224a31d7be577112f99ec45))
|
||||
* dns-hole widgets showing stale disconnected state ([#6096](https://github.com/homarr-labs/homarr/issues/6096)) ([2443f1c](https://github.com/homarr-labs/homarr/commit/2443f1cd3ff6765a91783d60d42aa03d9ba63fcd))
|
||||
* gracefully degrade when widget_secret table is missing or access denied ([30107e4](https://github.com/homarr-labs/homarr/commit/30107e4e76a454ff7402de9a8bc2e0dd6f422aa8))
|
||||
* handle missing widgetSecrets table and add default releases repo ([af35a50](https://github.com/homarr-labs/homarr/commit/af35a50fa05fee2f0118e98727eb108b0200f762))
|
||||
* **immich:** v3 API compatibility — use searchAssets for album assets, drop deviceAssetId ([#5977](https://github.com/homarr-labs/homarr/issues/5977)) ([a5ec880](https://github.com/homarr-labs/homarr/commit/a5ec880eec276e0814d2ab3ed7f9bc0b804d0465))
|
||||
* quick wins — calendar, about, glances, admin UX, releases, iframe, notes ([#6116](https://github.com/homarr-labs/homarr/issues/6116)) ([f9da71a](https://github.com/homarr-labs/homarr/commit/f9da71a19bdfa589aafe26b164d0e0782208ac0a))
|
||||
* restore ArchiveTeam Warrior as a proper integration ([8a5ad58](https://github.com/homarr-labs/homarr/commit/8a5ad5861ba4c2d0978f1fa3f6115a1c5974f8a2))
|
||||
* **timetable:** restore station search by removing disabled query guard ([d670b69](https://github.com/homarr-labs/homarr/commit/d670b6939672932c1a3d17ab9913901491cb46e3))
|
||||
* update CHANGES.md for ArchiveTeam Warrior restoration, remove typo translation key ([62baaf7](https://github.com/homarr-labs/homarr/commit/62baaf78130c4b74e899a948cee17097d8c02593))
|
||||
* verbose error messages in all release providers ([2155f13](https://github.com/homarr-labs/homarr/commit/2155f13f245f601b99458e227b6e058fbfa44cd9))
|
||||
* **widgets:** make downloads widget table background transparent ([#6025](https://github.com/homarr-labs/homarr/issues/6025)) ([73bed51](https://github.com/homarr-labs/homarr/commit/73bed51df895250f415efd7a87e721e403b3fd99))
|
||||
|
||||
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -159,6 +159,10 @@ packages/integrations/src/search-ch @Meierschlumpf
|
||||
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
|
||||
packages/integrations/src/speedtest-tracker @kylegrieder
|
||||
|
||||
# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap
|
||||
apps/docs/docs/integrations/synology @mrjoshuap
|
||||
packages/integrations/src/synology @mrjoshuap
|
||||
|
||||
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
|
||||
apps/docs/docs/integrations/technitium-dns @lopsch
|
||||
packages/integrations/src/technitium @lopsch
|
||||
|
||||
@@ -39,13 +39,26 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
||||
'Scroll down to "API Keys"',
|
||||
'Click "New API Key"',
|
||||
'Enter a name, e.g. "Homarr"',
|
||||
(<span>
|
||||
Select the following permissions:{" "}
|
||||
<code>album.read</code>,{" "}
|
||||
<code>user.read</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>asset.download</code>
|
||||
</span>),
|
||||
(
|
||||
|
||||
<span>
|
||||
|
||||
Select the following permissions:{" "}
|
||||
<code>album.statistics</code>,{" "}
|
||||
<code>album.read</code>,{" "}
|
||||
<code>album.create</code>,{" "}
|
||||
<code>user.read</code>,{" "}
|
||||
<code>user.update</code>,{" "}
|
||||
<code>asset.read</code>,{" "}
|
||||
<code>asset.download</code>,{" "}
|
||||
<code>asset.statistics</code>,{" "}
|
||||
<code>server.statistics</code>,{" "}
|
||||
<code>server.about</code>.
|
||||
<br />
|
||||
Additional permissions may be required depending on your Immich version. If you encounter authorization errors, grant any missing permissions requested by the server.
|
||||
</span>
|
||||
|
||||
),
|
||||
'Create the integration in Homarr and copy the API key to there'
|
||||
]
|
||||
}]} />
|
||||
}]} />
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: "Synology DiskStation"
|
||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { synologyIntegration } from ".";
|
||||
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
|
||||
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
|
||||
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
|
||||
import Alert from "@theme/Admonition";
|
||||
|
||||
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: healthMonitoringWidget },
|
||||
{ widget: systemResourcesWidget },
|
||||
{ widget: systemDisksWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Supported metrics
|
||||
|
||||
| Widget section | DSM API source | May be unavailable |
|
||||
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
|
||||
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
|
||||
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
|
||||
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
|
||||
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
|
||||
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
|
||||
| GPU | Not available | Typical DiskStation models |
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
<Alert type="info" title="Use the correct DSM URL">
|
||||
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
|
||||
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
|
||||
<a href="/docs/management/certificates">Management > Tools > Certificates</a> so Homarr can connect securely.
|
||||
</Alert>
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Create a dedicated DSM user with a strong password for Homarr.",
|
||||
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
|
||||
"Under User Groups and Permissions, deny access to shared folders and most applications.",
|
||||
"Allow login to DSM only, optionally restricted by source IP address.",
|
||||
"Log in once with the service account in DSM to accept terms and conditions.",
|
||||
],
|
||||
body: (
|
||||
<>
|
||||
<Alert type="caution" title="Administrators group required">
|
||||
Homarr reads system, storage, and utilization data from APIs that require membership in the default
|
||||
Administrators group.
|
||||
</Alert>
|
||||
<Alert type="warning" title="Two-factor authentication is not supported">
|
||||
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
|
||||
account without 2FA.
|
||||
</Alert>
|
||||
<Alert type="tip" title="First-time setup">
|
||||
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
|
||||
> Security > Protection, then re-enable it after Homarr connects successfully.
|
||||
</Alert>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Storage volume selection
|
||||
|
||||
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
|
||||
|
||||
<Alert type="note" title="Synology-only option">
|
||||
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
|
||||
</Alert>
|
||||
|
||||
<Alert type="note" title="Volume ordering">
|
||||
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
|
||||
</Alert>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const synologyIntegration: IntegrationDefinition = {
|
||||
name: "Synology DiskStation",
|
||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
|
||||
path: "../../integrations/synology",
|
||||
};
|
||||
@@ -1,61 +1,71 @@
|
||||
---
|
||||
title: 'System Health Monitoring'
|
||||
description: 'Displays information showing the health and status of your system(s).'
|
||||
title: "System Health Monitoring"
|
||||
description: "Displays information showing the health and status of your system(s)."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { healthMonitoringWidget } from '.';
|
||||
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { healthMonitoringWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={healthMonitoringWidget}
|
||||
categories={['Health monitoring']}
|
||||
/>
|
||||
<WidgetHeader widget={healthMonitoringWidget} categories={["Health monitoring"]} />
|
||||
|
||||
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import systemView from './img/system-view.png';
|
||||
import clusterView from './img/cluster-view.png';
|
||||
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
import systemView from "./img/system-view.png";
|
||||
import clusterView from "./img/cluster-view.png";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
|
||||
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: dashDotIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: openMediaVaultIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: truenasIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: glancesIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: proxmoxIntegration,
|
||||
note: "Will display information on the cluster tab, only supports one integration at a time"
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: glancesIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: proxmoxIntegration,
|
||||
note: "Will display information on the cluster tab, only supports one integration at a time",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
|
||||
@@ -38,6 +38,13 @@ export const healthMonitoringWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Visible storage volumes",
|
||||
description:
|
||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||
values: "List of storage volumes",
|
||||
defaultValue: "Empty list",
|
||||
},
|
||||
{
|
||||
name: "Default tab",
|
||||
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "System disks"
|
||||
description: "Disk usage of your system"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { systemDisksWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { unraidIntegration } from "@site/docs/integrations/unraid";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
},
|
||||
{
|
||||
integration: unraidIntegration,
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
note: "Supports storage volume filtering when only Synology integrations are selected",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={systemDisksWidget.configuration} />
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconServer2 } from "@tabler/icons-react";
|
||||
|
||||
export const systemDisksWidget: WidgetDefinition = {
|
||||
icon: IconServer2,
|
||||
name: "System disks",
|
||||
description: "Disk usage of your system",
|
||||
path: "../../widgets/system-disks",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show temperature if available",
|
||||
description: "Display disk temperature when the integration provides it",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "Yes",
|
||||
},
|
||||
{
|
||||
name: "Display mode",
|
||||
description: "How disk usage values are shown",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
|
||||
},
|
||||
defaultValue: "Percentage (e.g., 76%)",
|
||||
},
|
||||
{
|
||||
name: "Show background usage bar",
|
||||
description: "Fill the area below the usage value with a background bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "Yes",
|
||||
},
|
||||
{
|
||||
name: "Visible storage volumes",
|
||||
description:
|
||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||
values: "List of storage volumes",
|
||||
defaultValue: "All volumes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,57 +1,69 @@
|
||||
---
|
||||
title: 'System Resources'
|
||||
description: 'Displays CPU, RAM and network of your host in real time'
|
||||
title: "System Resources"
|
||||
description: "Displays CPU, RAM and network of your host in real time"
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { systemResourcesWidget } from '.';
|
||||
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { systemResourcesWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={systemResourcesWidget}
|
||||
categories={['CPU', 'RAM', 'Memory', 'Network', 'Interface']}
|
||||
/>
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
|
||||
### Screenshots
|
||||
|
||||
import network from './img/network.png';
|
||||
import cpu from './img/cpu.png';
|
||||
import memory from './img/memory.png';
|
||||
import cpuAndMemory from './img/cpu-and-memory.png';
|
||||
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
import network from "./img/network.png";
|
||||
import cpu from "./img/cpu.png";
|
||||
import memory from "./img/memory.png";
|
||||
import cpuAndMemory from "./img/cpu-and-memory.png";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
|
||||
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
|
||||
</div>
|
||||
</div>
|
||||
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
|
||||
<img
|
||||
src={cpuAndMemory}
|
||||
alt="system-resources-widget cpu-and-memory icon-text-label"
|
||||
width={256 + 4}
|
||||
height={256 + 4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: dashDotIntegration
|
||||
}, {
|
||||
integration: openMediaVaultIntegration
|
||||
}, {
|
||||
integration: truenasIntegration
|
||||
}, {
|
||||
integration: glancesIntegration
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
},
|
||||
{
|
||||
integration: glancesIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -59,4 +71,4 @@ import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||
@@ -1,18 +1,25 @@
|
||||
// Algolia DocSearch crawler for homarr.dev
|
||||
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
|
||||
// DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
|
||||
// Template: https://docsearch.algolia.com/docs/templates/#docusaurus-v3-template
|
||||
// Markdown indexing: https://www.algolia.com/doc/guides/algolia-ai/askai/guides/markdown-indexing
|
||||
// Paste this in the Algolia crawler editor.
|
||||
|
||||
new Crawler({
|
||||
appId: "N69WSPZTID",
|
||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||
rateLimit: 8,
|
||||
maxDepth: 10,
|
||||
indexPrefix: "",
|
||||
rateLimit: 32,
|
||||
maxDepth: 4,
|
||||
startUrls: ["https://homarr.dev/"],
|
||||
sitemaps: ["https://homarr.dev/sitemap.xml"],
|
||||
ignoreCanonicalTo: true,
|
||||
discoveryPatterns: ["https://homarr.dev/**"],
|
||||
exclusionPatterns: [
|
||||
"https://homarr.dev/docs/tags/**",
|
||||
"https://homarr.dev/blog/tags/**",
|
||||
"https://homarr.dev/blog/authors/**",
|
||||
"https://homarr.dev/blog/archive/**",
|
||||
"https://homarr.dev/search/**",
|
||||
"https://homarr.dev/docs/category/**",
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
indexName: "Docusaurus",
|
||||
@@ -105,7 +112,6 @@ new Crawler({
|
||||
advancedSyntax: true,
|
||||
attributeCriteriaComputedByMinProximity: true,
|
||||
removeWordsIfNoResults: "allOptional",
|
||||
separatorsToIndex: "_",
|
||||
},
|
||||
markdown: {
|
||||
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
|
||||
@@ -119,4 +125,10 @@ new Crawler({
|
||||
advancedSyntax: false,
|
||||
},
|
||||
},
|
||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||
schedule: "on saturday",
|
||||
renderJavaScript: false,
|
||||
maxUrls: null,
|
||||
cache: { enabled: false },
|
||||
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const config: Config = {
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
url: "https://homarr.dev",
|
||||
baseUrl: "/",
|
||||
trailingSlash: true,
|
||||
trailingSlash: undefined,
|
||||
favicon: "img/logo.png",
|
||||
organizationName: "homarr-labs",
|
||||
projectName: "homarr",
|
||||
@@ -79,8 +79,27 @@ const config: Config = {
|
||||
sitemap: {
|
||||
changefreq: "weekly",
|
||||
priority: 0.5,
|
||||
ignorePatterns: ["/tags/**"],
|
||||
ignorePatterns: ["/tags/**", "/docs/category/**"],
|
||||
filename: "sitemap.xml",
|
||||
createSitemapItems: async ({ routes, siteConfig, defaultCreateSitemapItems }) => {
|
||||
const items = await defaultCreateSitemapItems({ routes, siteConfig });
|
||||
return items.map((item) => {
|
||||
const path = new URL(item.url).pathname;
|
||||
if (path === "/" || path === "") {
|
||||
return { ...item, priority: 1.0, changefreq: "weekly" };
|
||||
}
|
||||
if (path.startsWith("/docs/")) {
|
||||
return { ...item, priority: 0.7, changefreq: "monthly" };
|
||||
}
|
||||
if (path.startsWith("/blog/")) {
|
||||
return { ...item, priority: 0.3, changefreq: "never" };
|
||||
}
|
||||
if (path.startsWith("/about-us")) {
|
||||
return { ...item, priority: 0.4, changefreq: "yearly" };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
},
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
|
||||
@@ -32,19 +32,19 @@
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
"leader-line-new": "^1.1.9",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss": "^8.5.16",
|
||||
"posthog-docusaurus": "^2.0.5",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^12.2.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
"swiper": "^14.0.1",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@import url("https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap");
|
||||
@import "tailwindcss/theme" layer(theme);
|
||||
@import "tailwindcss/utilities" layer(utilities);
|
||||
|
||||
@import url(https://api.fontshare.com/v2/css?f[]=work-sans@2,1&display=swap);
|
||||
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"target": "es5",
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -12,10 +9,14 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@site/*": ["./*"]
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
|
||||
+12
-14
@@ -5,21 +5,9 @@ import "@homarr/common/env";
|
||||
import "@homarr/core/infrastructure/logs/env";
|
||||
import "@homarr/docker/env";
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const homarrVersion = (() => {
|
||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
||||
if (pkg.name === "homarr") return pkg.version as string;
|
||||
} catch {}
|
||||
}
|
||||
return "unknown";
|
||||
})();
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin({
|
||||
requestConfig: "../../packages/translation/src/request.ts",
|
||||
@@ -27,7 +15,7 @@ const withNextIntl = createNextIntlPlugin({
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: {
|
||||
HOMARR_VERSION: homarrVersion,
|
||||
HOMARR_VERSION: process.env.HOMARR_VERSION ?? "unknown",
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
@@ -43,9 +31,19 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
turbopack: {
|
||||
// ponytail: known Turbopack NFT warning from path.join(process.cwd(), …) in
|
||||
// src/app/api/backup/{route,shared}.ts. No working placement in 16.2.x
|
||||
// (see vercel/next.js#95125). Suppress until upstream fix lands.
|
||||
ignoreIssue: [
|
||||
{
|
||||
path: "**/*",
|
||||
title: "Encountered unexpected file in NFT list",
|
||||
},
|
||||
],
|
||||
},
|
||||
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
|
||||
images: {
|
||||
localPatterns: [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
|
||||
"clean": "git clean -xdf .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "oxfmt .",
|
||||
@@ -59,10 +59,11 @@
|
||||
"@modelcontextprotocol/sdk": "catalog:",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tanstack/query-persist-client-core": "catalog:",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-query-next-experimental": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
|
||||
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
|
||||
import { parse, stringify } from "superjson";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import {
|
||||
getActiveQueryCacheBoardId,
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheStoragePrefix,
|
||||
} from "@homarr/api/query-cache";
|
||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||
|
||||
const queryCacheStorage = {
|
||||
async getItem(_key) {
|
||||
return null;
|
||||
},
|
||||
async setItem(key, value) {
|
||||
getItem: (_key: string) => null as string | null,
|
||||
async setItem(_key: string, value: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
|
||||
try {
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
|
||||
} catch {
|
||||
// Query persistence is best-effort; failed writes should not affect widget rendering.
|
||||
}
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||
console.warn("[query-cache] persist failed", error);
|
||||
});
|
||||
},
|
||||
async removeItem(key) {
|
||||
async removeItem(_key: string) {
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
|
||||
try {
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
|
||||
} catch {
|
||||
// Query persistence is best-effort; failed removals should not affect widget rendering.
|
||||
}
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||
console.warn("[query-cache] remove failed", error);
|
||||
});
|
||||
},
|
||||
} satisfies AsyncStorage<string>;
|
||||
};
|
||||
|
||||
export const createWidgetQueryPersister = () =>
|
||||
experimental_createQueryPersister<string>({
|
||||
createAsyncStoragePersister({
|
||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||
maxAge: Number.POSITIVE_INFINITY,
|
||||
prefix: queryCacheStoragePrefix,
|
||||
refetchOnRestore: true,
|
||||
serialize: (query) => stringify(query),
|
||||
deserialize: (value) => parse<PersistedQuery>(value),
|
||||
filters: {
|
||||
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
key: queryCacheStoragePrefix,
|
||||
throttleTime: 2000,
|
||||
serialize: (data) => superjson.stringify(data),
|
||||
deserialize: (data) => superjson.parse(data),
|
||||
});
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
@@ -21,7 +22,13 @@ import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
|
||||
import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheBuster,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheDefaultRefetchIntervalMs,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
@@ -29,7 +36,6 @@ import { showWarningNotification } from "@homarr/notifications";
|
||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
if (typeof window === "undefined") {
|
||||
return "ws";
|
||||
}
|
||||
@@ -55,32 +61,36 @@ const wsClient = createWSClient({
|
||||
});
|
||||
|
||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
const [queryPersister] = useState(() => createWidgetQueryPersister());
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
persister: queryPersister.persisterFn,
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "Mutations are disabled in demo mode"
|
||||
) {
|
||||
showWarningNotification({
|
||||
title: "Demo mode",
|
||||
message: "This action is disabled in demo mode.",
|
||||
});
|
||||
}
|
||||
},
|
||||
const [persister] = useState(() => createWidgetQueryPersister());
|
||||
const [queryClient] = useState(() => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: queryCacheDefaultStaleTimeMs,
|
||||
gcTime: queryCacheDefaultGcTimeMs,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 3,
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "Mutations are disabled in demo mode"
|
||||
) {
|
||||
showWarningNotification({
|
||||
title: "Demo mode",
|
||||
message: "This action is disabled in demo mode.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
|
||||
return client;
|
||||
});
|
||||
|
||||
const [trpcClient] = useState(() => {
|
||||
return clientApi.createClient({
|
||||
@@ -98,9 +108,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
false: splitLink({
|
||||
condition: ({ input }) => isNonJsonSerializable(input),
|
||||
true: httpLink({
|
||||
/**
|
||||
* We don't want to transform the data here as we want to use form data
|
||||
*/
|
||||
transformer: {
|
||||
serialize(object: unknown) {
|
||||
return object;
|
||||
@@ -115,7 +122,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
false: httpBatchStreamLink({
|
||||
transformer: superjson,
|
||||
url: getTrpcUrl(),
|
||||
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
|
||||
maxURLLength: 2083,
|
||||
headers: createHeadersCallbackForSource("nextjs-react (json)"),
|
||||
}),
|
||||
}),
|
||||
@@ -126,10 +133,21 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
|
||||
return (
|
||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
buster: queryCacheBuster,
|
||||
maxAge: queryCacheDefaultGcTimeMs,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</clientApi.Provider>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { TRPCError } from "@trpc/server";
|
||||
// Placed here because gridstack styles are used for board content
|
||||
import "~/styles/gridstack.scss";
|
||||
|
||||
import type { DehydratedState } from "@tanstack/react-query";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||
import superjson from "superjson";
|
||||
import { queryCacheBuster } from "@homarr/api/query-cache";
|
||||
import { getQueryClient } from "@homarr/api/server";
|
||||
import { IntegrationProvider } from "@homarr/auth/client";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -17,7 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getAllQueryCacheAsync } from "@homarr/redis";
|
||||
import { getQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
@@ -118,36 +118,18 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
};
|
||||
};
|
||||
|
||||
interface PersistedEntry {
|
||||
state: { data: unknown; dataUpdatedAt: number; status: string };
|
||||
queryKey: unknown[];
|
||||
queryHash: string;
|
||||
}
|
||||
|
||||
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||
try {
|
||||
const entries = await getAllQueryCacheAsync(userId, boardId);
|
||||
const queries: DehydratedState["queries"] = [];
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
for (const serialized of Object.values(entries)) {
|
||||
try {
|
||||
const persisted = parse<PersistedEntry>(serialized);
|
||||
if (persisted.state.status !== "success") continue;
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
|
||||
queries.push({
|
||||
state: persisted.state as DehydratedState["queries"][number]["state"],
|
||||
queryKey: persisted.queryKey,
|
||||
queryHash: persisted.queryHash,
|
||||
});
|
||||
} catch {
|
||||
// skip malformed entries
|
||||
}
|
||||
}
|
||||
|
||||
if (queries.length === 0) return null;
|
||||
|
||||
return <HydrationBoundary state={{ mutations: [], queries }} />;
|
||||
} catch {
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,9 @@ export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
|
||||
// edit/save/settings UI that would then fail server-side.
|
||||
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
|
||||
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
return <SelectBoardsMenu />;
|
||||
@@ -55,9 +57,9 @@ export const BoardContentHeaderActions = () => {
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoMode && (
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
@@ -158,7 +160,7 @@ const AddMenu = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -188,11 +190,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) {
|
||||
if (demoMode) return discardDemoChanges();
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
@@ -156,29 +157,26 @@ interface DockerTableProps {
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const mutate = useCallback(() => {
|
||||
void refetch().then((result) => {
|
||||
if (result.isError) {
|
||||
showErrorNotification({
|
||||
title: tDocker("action.refresh.notification.error.title"),
|
||||
message: tDocker("action.refresh.notification.error.message"),
|
||||
});
|
||||
} else {
|
||||
showSuccessNotification({
|
||||
title: tDocker("action.refresh.notification.success.title"),
|
||||
message: tDocker("action.refresh.notification.success.message"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [refetch, tDocker]);
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
|
||||
@@ -207,7 +205,12 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
<Button
|
||||
variant="default"
|
||||
rightSection={<IconRefresh size="1rem" />}
|
||||
onClick={() => mutate()}
|
||||
loading={isFetching}
|
||||
>
|
||||
{tDocker("action.refresh.label")}
|
||||
</Button>
|
||||
),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const migrationsFolderCandidates = [
|
||||
path.join(process.cwd(), "db/migrations/sqlite"),
|
||||
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
||||
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
||||
"/app/db/migrations/sqlite",
|
||||
];
|
||||
|
||||
export const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
export const findMigrationsFolder = () => {
|
||||
const cwd = process.cwd();
|
||||
const migrationsFolderCandidates = [
|
||||
path.join(cwd, "db/migrations/sqlite"),
|
||||
path.join(cwd, "packages/db/migrations/sqlite"),
|
||||
path.resolve(cwd, "../../db/migrations/sqlite"),
|
||||
"/app/db/migrations/sqlite",
|
||||
];
|
||||
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
@@ -105,12 +105,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
onReset={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Group, Menu, Switch } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -45,6 +47,13 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
|
||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||
}, [queryClient, widgetQueryKey]);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||
@@ -214,7 +223,19 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
)}
|
||||
|
||||
<>
|
||||
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{tMenu("refresh")}
|
||||
<Text size="xs" c="dimmed">
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||
</Text>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconSettings size={16} />}
|
||||
@@ -248,3 +269,35 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
queryKey: unknown[];
|
||||
isFetching: boolean;
|
||||
}) => {
|
||||
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
if (isFetching) return <Loader size={12} />;
|
||||
|
||||
const timestamps = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey })
|
||||
.map((q) => q.state.dataUpdatedAt)
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) return null;
|
||||
|
||||
const oldest = Math.min(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - oldest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
};
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
return (
|
||||
<AppShellHeader maw="100vw" style={{ overflowX: "hidden" }}>
|
||||
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <ClientBurger />}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { invalidateUpdateCheckerCacheAsync } from "./invalidate-update-checker-cache";
|
||||
import { cleanupSessionsAsync } from "./session-cleanup";
|
||||
import { warmUpdateCheckerAsync } from "./warm-update-checker";
|
||||
|
||||
export async function onStartAsync() {
|
||||
await cleanupSessionsAsync();
|
||||
await invalidateUpdateCheckerCacheAsync();
|
||||
await warmUpdateCheckerAsync();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
const logger = createLogger({ module: "invalidateUpdateCheckerCache" });
|
||||
|
||||
/**
|
||||
* Invalidates the update checker cache on startup to ensure fresh data.
|
||||
* It is important as we want to avoid showing pending updates after the update to latest version.
|
||||
*/
|
||||
export async function invalidateUpdateCheckerCacheAsync() {
|
||||
try {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
await handler.invalidateAsync();
|
||||
logger.debug("Update checker cache invalidated");
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to invalidate update checker cache", { cause: error }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
||||
|
||||
const logger = createLogger({ module: "warmUpdateChecker" });
|
||||
|
||||
export async function warmUpdateCheckerAsync() {
|
||||
try {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
await handler.getDataAsync();
|
||||
logger.debug("Update checker warmed");
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to warm update checker", { cause: error }));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "homarr-prebuilt-debian",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.10.0"
|
||||
"better-sqlite3": "^12.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.18.0",
|
||||
|
||||
+5
-5
@@ -9,16 +9,16 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
better-sqlite3:
|
||||
specifier: ^12.10.0
|
||||
version: 12.10.0
|
||||
specifier: ^12.11.1
|
||||
version: 12.11.1
|
||||
|
||||
packages:
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
better-sqlite3@12.10.0:
|
||||
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
|
||||
better-sqlite3@12.11.1:
|
||||
resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
|
||||
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
|
||||
|
||||
bindings@1.5.0:
|
||||
@@ -148,7 +148,7 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
better-sqlite3@12.10.0:
|
||||
better-sqlite3@12.11.1:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
+27
-83
@@ -102,20 +102,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/codeberg" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg" alt="Codeberg" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Codeberg</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Coolify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||
<br/>
|
||||
@@ -130,13 +123,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/docker-hub" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg" alt="Docker Hub" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Docker<br/>Hub</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/emby" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg" alt="Emby" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -144,27 +130,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/github" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg" alt="Github" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Github</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/github" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg" alt="GitHub Container Registry" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">GitHub<br/>Container<br/>Registry</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gitlab" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg" alt="Gitlab" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Gitlab</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg" alt="Glances" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -172,6 +137,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gluetun" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg" alt="Gluetun" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Gluetun</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gotify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg" alt="Gotify" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -184,8 +156,8 @@
|
||||
<br/>
|
||||
<p align="center">Home<br/>Assistant</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -205,8 +177,8 @@
|
||||
<br/>
|
||||
<p align="center">Jellyfin</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/jellyseerr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg" alt="Jellyseerr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -221,13 +193,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">LinuxServer.io</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg" alt="Navidrome" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -240,22 +205,15 @@
|
||||
<br/>
|
||||
<p align="center">Nextcloud</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/npm" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg" alt="NPM" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">NPM</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">ntfy</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/nzbget" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg" alt="NZBGet" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -296,15 +254,15 @@
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -333,13 +291,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/quay" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png" alt="Quay" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Quay</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -361,13 +312,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/search-ch" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png" alt="Search.ch" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Search.ch</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -401,15 +345,15 @@
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Technitium<br/>DNS</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -450,8 +394,8 @@
|
||||
<br/>
|
||||
<p align="center">Unraid</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg" alt="Uptime Kuma" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.68.1",
|
||||
"version": "1.69.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -43,6 +43,7 @@
|
||||
"@semantic-release/release-notes-generator": "catalog:",
|
||||
"@testcontainers/redis": "catalog:",
|
||||
"@turbo/gen": "catalog:",
|
||||
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
|
||||
@@ -36,8 +36,6 @@ const logger = createLogger({ module: "analytics" });
|
||||
|
||||
type AnalyticsResult = "sent" | "disabled" | "failed";
|
||||
|
||||
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
|
||||
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
|
||||
const getOrCreateInstanceId = async (
|
||||
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
|
||||
): Promise<string> => {
|
||||
|
||||
@@ -6,9 +6,11 @@ export const env = createEnv({
|
||||
server: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
},
|
||||
runtimeEnv: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
|
||||
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
|
||||
export const queryCacheDefaultStaleTimeMs = 0;
|
||||
export const queryCacheDefaultRefetchIntervalMs = 30_000;
|
||||
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
||||
export const queryCacheBuster = "v2-superjson";
|
||||
|
||||
let activeQueryCacheBoardId: string | null = null;
|
||||
|
||||
|
||||
@@ -25,9 +25,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
.concat(dockerMiddleware())
|
||||
.query(async () => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
const result = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const result = await innerHandler.getDataAsync();
|
||||
|
||||
const { data, timestamp } = result;
|
||||
|
||||
@@ -36,26 +34,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
timestamp,
|
||||
};
|
||||
}),
|
||||
subscribeContainers: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.subscription(() => {
|
||||
return observable<DockerContainer[]>((emit) => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next(data);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
}),
|
||||
invalidate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(async () => {
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
startAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
@@ -73,9 +51,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
await container.start();
|
||||
}),
|
||||
);
|
||||
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
stopAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -94,9 +69,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
await container.stop();
|
||||
}),
|
||||
);
|
||||
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
restartAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -116,9 +88,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
await container.restart();
|
||||
}),
|
||||
);
|
||||
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
removeAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
@@ -138,9 +107,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
await container.remove();
|
||||
}),
|
||||
);
|
||||
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -30,8 +30,6 @@ export const iconsRouter = createTRPCRouter({
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
// ponytail: SVG first, then alphabetical; with a search term,
|
||||
// prepend a relevance tier (exact > prefix > substring).
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
export const infoRouter = createTRPCRouter({
|
||||
getInfo: protectedProcedure
|
||||
@@ -20,4 +20,5 @@ export const infoRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
|
||||
});
|
||||
@@ -2,53 +2,41 @@ import { z } from "zod/v4";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { boards } from "@homarr/db/schema";
|
||||
import { createQueryCacheChannel } from "@homarr/redis";
|
||||
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
|
||||
import { createTRPCRouter, internalProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
|
||||
const queryCacheKeyInput = z.object({
|
||||
const queryCacheBoardInput = z.object({
|
||||
boardId: z.string().min(1),
|
||||
key: z.string().min(1).max(4096),
|
||||
});
|
||||
|
||||
const queryCacheSetInput = queryCacheKeyInput.extend({
|
||||
const queryCacheSetInput = queryCacheBoardInput.extend({
|
||||
value: z.string().max(queryCacheMaxValueBytes * 2),
|
||||
});
|
||||
|
||||
const createChannel = (input: z.infer<typeof queryCacheKeyInput>, userId: string) =>
|
||||
createQueryCacheChannel({
|
||||
userId,
|
||||
boardId: input.boardId,
|
||||
key: input.key,
|
||||
ttlMs: queryCacheRetentionMs,
|
||||
maxValueBytes: queryCacheMaxValueBytes,
|
||||
});
|
||||
|
||||
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
|
||||
};
|
||||
|
||||
export const queryCacheRouter = createTRPCRouter({
|
||||
getItem: publicProcedure.input(queryCacheKeyInput).query(async ({ ctx, input }) => {
|
||||
setItem: internalProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
|
||||
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
|
||||
return await channel.getAsync();
|
||||
}),
|
||||
setItem: publicProcedure.input(queryCacheSetInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
|
||||
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
|
||||
const userId = ctx.session?.user.id ?? "anonymous";
|
||||
return {
|
||||
stored: await channel.setAsync(input.value),
|
||||
stored: await setQueryCacheAsync(
|
||||
userId,
|
||||
input.boardId,
|
||||
input.value,
|
||||
queryCacheDefaultGcTimeMs,
|
||||
queryCacheMaxValueBytes,
|
||||
),
|
||||
};
|
||||
}),
|
||||
removeItem: publicProcedure.input(queryCacheKeyInput).mutation(async ({ ctx, input }) => {
|
||||
removeItem: internalProcedure.input(queryCacheBoardInput).mutation(async ({ ctx, input }) => {
|
||||
await validateBoardAccessAsync(ctx, input.boardId);
|
||||
|
||||
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
|
||||
await channel.removeAsync();
|
||||
const userId = ctx.session?.user.id ?? "anonymous";
|
||||
await removeQueryCacheAsync(userId, input.boardId);
|
||||
}),
|
||||
});
|
||||
@@ -23,28 +23,12 @@ vi.mock("@homarr/request-handler/docker", () => ({
|
||||
},
|
||||
dockerContainersRequestHandler: {
|
||||
handler: () => ({
|
||||
getCachedOrUpdatedDataAsync: async () => {
|
||||
return await Promise.resolve({ containers: [] });
|
||||
},
|
||||
invalidateAsync: async () => {
|
||||
return await Promise.resolve();
|
||||
getDataAsync: async () => {
|
||||
return await Promise.resolve({ data: [], timestamp: new Date() });
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createCacheChannel: () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
consumeAsync: async () => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { containers: [] },
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
invalidateAsync: async () => {},
|
||||
}),
|
||||
createWidgetOptionsChannel: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/docker/env", () => ({
|
||||
env: {
|
||||
ENABLE_DOCKER: true,
|
||||
@@ -67,12 +51,10 @@ const validInputs: {
|
||||
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
||||
} = {
|
||||
getContainers: undefined,
|
||||
subscribeContainers: undefined,
|
||||
startAll: { ids: ["1"] },
|
||||
stopAll: { ids: ["1"] },
|
||||
restartAll: { ids: ["1"] },
|
||||
removeAll: { ids: ["1"] },
|
||||
invalidate: undefined,
|
||||
logs: { id: "1", tail: 200 },
|
||||
subscribeLogs: { id: "1", tail: 200 },
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export const updateCheckerRouter = createTRPCRouter({
|
||||
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||
try {
|
||||
const handler = updateCheckerRequestHandler.handler({});
|
||||
const data = await handler.getCachedOrUpdatedDataAsync({});
|
||||
const data = await handler.getDataAsync();
|
||||
return data.data.availableUpdates;
|
||||
} catch (error) {
|
||||
logger.error(new Error("Failed to get available updates", { cause: error }));
|
||||
|
||||
@@ -45,9 +45,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
|
||||
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data } = await handler.getDataAsync();
|
||||
|
||||
return data;
|
||||
}),
|
||||
@@ -57,9 +55,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data } = await handler.getDataAsync();
|
||||
|
||||
return data;
|
||||
}),
|
||||
@@ -79,9 +75,6 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||
});
|
||||
|
||||
const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
await noteHandler.invalidateAsync();
|
||||
|
||||
return updatedNote;
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError && error.statusCode === 400) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { sendPingRequestAsync } from "@homarr/ping";
|
||||
import { pingChannel, pingUrlChannel } from "@homarr/redis";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { AppRepository } from "../app";
|
||||
@@ -45,55 +43,4 @@ export const appRouter = createTRPCRouter({
|
||||
...pingResult,
|
||||
};
|
||||
}),
|
||||
updatedPing: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.subscription(async ({ ctx, input }) => {
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
if (boardSettings.forceDisableStatus) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Ping functionality is disabled by server settings",
|
||||
});
|
||||
}
|
||||
|
||||
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
|
||||
const app = await repository.getByIdAsync(input.id);
|
||||
|
||||
if (!app) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "App not found",
|
||||
});
|
||||
}
|
||||
|
||||
const pingUrl = app.pingUrl ?? app.href;
|
||||
|
||||
if (!pingUrl) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "No URL to ping configured for this app",
|
||||
});
|
||||
}
|
||||
|
||||
await pingUrlChannel.addAsync(pingUrl);
|
||||
|
||||
return observable<{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }>(
|
||||
(emit) => {
|
||||
const unsubscribe = pingChannel.subscribe((message) => {
|
||||
// Only emit if same url
|
||||
if (message.url !== pingUrl) return;
|
||||
emit.next(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
void pingUrlChannel.removeAsync(pingUrl);
|
||||
};
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,3 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -11,29 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data, timestamp } = await handler.getDataAsync();
|
||||
|
||||
return {
|
||||
status: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
|
||||
subscribeStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const unsubscribe = handler.subscribe((status) => {
|
||||
emit.next({
|
||||
status,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -13,14 +13,14 @@ export const audioStatsRouter = createTRPCRouter({
|
||||
const fetchByKind = {
|
||||
navidrome: async () => {
|
||||
const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
|
||||
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
|
||||
return (await handler.getDataAsync()).data;
|
||||
},
|
||||
audiobookshelf: async () => {
|
||||
const handler = audiobookshelfRequestHandler.handler(
|
||||
{ ...ctx.integration, kind: "audiobookshelf" as const },
|
||||
{},
|
||||
);
|
||||
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
|
||||
return (await handler.getDataAsync()).data;
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
beszelStatsRequestHandler,
|
||||
beszelSystemsRequestHandler,
|
||||
} from "@homarr/request-handler/beszel";
|
||||
import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -33,9 +29,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
@@ -71,30 +65,6 @@ export const beszelRouter = createTRPCRouter({
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeSystems: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
systems: BeszelSystemRow[];
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((systems) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
systems,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribes.forEach((fn) => fn());
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getAlerts: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
@@ -125,13 +95,17 @@ export const beszelRouter = createTRPCRouter({
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const [alertsResult, systemsResult] = await Promise.all([
|
||||
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
|
||||
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
|
||||
const [alertsSettled, systemsSettled] = await Promise.allSettled([
|
||||
alertsHandler.getDataAsync(),
|
||||
systemsHandler.getDataAsync(),
|
||||
]);
|
||||
if (alertsSettled.status === "rejected") throw alertsSettled.reason;
|
||||
const alertsResult = alertsSettled.value;
|
||||
const systemNameMap: Record<string, string> = {};
|
||||
for (const system of systemsResult.data) {
|
||||
systemNameMap[system.id] = system.name;
|
||||
if (systemsSettled.status === "fulfilled") {
|
||||
for (const system of systemsSettled.value.data) {
|
||||
systemNameMap[system.id] = system.name;
|
||||
}
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
@@ -170,39 +144,6 @@ export const beszelRouter = createTRPCRouter({
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeAlerts: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
includeHistory: z.boolean().default(true),
|
||||
maxHistoryItems: z.number().min(1).max(100).default(10),
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
alerts: BeszelAlertsData;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
return innerHandler.subscribe((data) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
alerts: data,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
unsubscribes.forEach((fn) => fn());
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getSystemStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
@@ -237,7 +178,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
logger.debug("getSystemStats completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
@@ -260,61 +201,4 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
stats: BeszelSystemStatsRecord;
|
||||
containerStats: BeszelContainerStatsRecord | null;
|
||||
}>((emit) => {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
emit.error(new Error("No Beszel integration configured"));
|
||||
return () => {};
|
||||
}
|
||||
logger.debug("subscribeSystemStats opened", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
logger.warn("subscribeSystemStats error", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error) {
|
||||
emit.error(error);
|
||||
} else {
|
||||
emit.error(new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
logger.debug("subscribeSystemStats closed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,13 +1,8 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
|
||||
@@ -39,9 +34,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
||||
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
events: data,
|
||||
@@ -72,39 +65,4 @@ export const calendarRouter = createTRPCRouter({
|
||||
return [];
|
||||
});
|
||||
}),
|
||||
subscribeToEvents: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number(),
|
||||
month: z.number(),
|
||||
releaseType: z.array(z.enum(radarrReleaseTypes)),
|
||||
showUnmonitored: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"calendar"> }>;
|
||||
events: CalendarEvent[];
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
||||
const innerHandler = calendarMonthRequestHandler.handler(integrationWithSecrets, handlerInput);
|
||||
const unsubscribe = innerHandler.subscribe((events) => {
|
||||
emit.next({
|
||||
integration,
|
||||
events,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,49 +1,23 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const coolifyRouter = createTRPCRouter({
|
||||
getInstancesInfo: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = coolifyRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = coolifyRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
instanceInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
subscribeInstancesInfo: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; instanceInfo: CoolifyInstanceInfo; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = coolifyRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((instanceInfo) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
instanceInfo,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
instanceInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
export const dnsHoleRouter = createTRPCRouter({
|
||||
@@ -23,50 +19,11 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeToSummary: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
|
||||
summary: DnsHoleSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
summary: data,
|
||||
};
|
||||
});
|
||||
}),
|
||||
@@ -83,12 +40,6 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.enableAsync();
|
||||
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||
// We need to wait for the integration to be enabled before invalidating the cache
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
|
||||
});
|
||||
}),
|
||||
|
||||
disable: protectedProcedure
|
||||
@@ -108,11 +59,5 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.disableAsync(input.duration);
|
||||
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||
// We need to wait for the integration to be disabled before invalidating the cache
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,16 +1,12 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -28,52 +24,12 @@ export const downloadsRouter = createTRPCRouter({
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeToJobsAndStatuses: publicProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||
data: DownloadClientJobsAndStatus;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {
|
||||
limit: input.limitPerIntegration,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next({
|
||||
integration,
|
||||
data,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
data,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type {
|
||||
FirewallCpuSummary,
|
||||
FirewallInterfacesSummary,
|
||||
FirewallMemorySummary,
|
||||
FirewallVersionSummary,
|
||||
} from "@homarr/integrations";
|
||||
import {
|
||||
firewallCpuRequestHandler,
|
||||
firewallInterfacesRequestHandler,
|
||||
@@ -18,198 +7,31 @@ import {
|
||||
} from "@homarr/request-handler/firewall";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
|
||||
|
||||
const queryFirewall = <
|
||||
THandler extends {
|
||||
handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
|
||||
},
|
||||
>(
|
||||
handler: THandler,
|
||||
) =>
|
||||
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
|
||||
settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export const firewallRouter = createTRPCRouter({
|
||||
getFirewallCpuStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = firewallCpuRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
subscribeFirewallCpuStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
|
||||
summary: FirewallCpuSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getFirewallInterfacesStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = firewallInterfacesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
subscribeFirewallInterfacesStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
|
||||
summary: FirewallInterfacesSummary[];
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getFirewallVersionStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = firewallVersionRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
subscribeFirewallVersionStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
|
||||
summary: FirewallVersionSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getFirewallMemoryStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = firewallMemoryRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
subscribeFirewallMemoryStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"firewall"> }>;
|
||||
summary: FirewallMemorySummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
|
||||
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
|
||||
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
|
||||
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
|
||||
});
|
||||
@@ -1,70 +1,59 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
||||
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { SynologyIntegration } from "@homarr/integrations";
|
||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const healthMonitoringIntegrationKinds = [
|
||||
"openmediavault",
|
||||
"dashDot",
|
||||
"truenas",
|
||||
"unraid",
|
||||
"glances",
|
||||
"synology",
|
||||
"mock",
|
||||
] as const;
|
||||
|
||||
export const healthMonitoringRouter = createTRPCRouter({
|
||||
getSystemHealthStatus: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(
|
||||
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeSystemHealthStatus: publicProcedure
|
||||
.concat(
|
||||
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||
)
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
healthInfo: SystemHealthMonitoring;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||
const unsubscribe = innerHandler.subscribe((healthInfo) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
healthInfo,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
listStorageVolumes: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "synology"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
if (!(integrationInstance instanceof SynologyIntegration)) {
|
||||
throw new Error("Expected Synology integration");
|
||||
}
|
||||
|
||||
return await integrationInstance.listStorageVolumesAsync();
|
||||
}),
|
||||
getClusterHealthStatus: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
@@ -76,24 +65,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
subscribeClusterHealthStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<ProxmoxClusterInfo>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const unsubscribe = innerHandler.subscribe((healthInfo) => {
|
||||
emit.next(healthInfo);
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
export const immichRouter = createTRPCRouter({
|
||||
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
@@ -30,13 +30,13 @@ export const immichRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
|
||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
|
||||
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { Indexer } from "@homarr/integrations/types";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -17,39 +16,13 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
getIndexersStatus: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
|
||||
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
|
||||
const { data: indexers } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
indexers,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeIndexersStatus: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((indexers) => {
|
||||
emit.next({
|
||||
integrationId: integrationWithSecrets.id,
|
||||
indexers,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
indexers,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { MediaRelease } from "@homarr/integrations/types";
|
||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
releases: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
releases: data,
|
||||
};
|
||||
});
|
||||
return results.flatMap((result) =>
|
||||
result.releases.map((release) => ({
|
||||
...release,
|
||||
@@ -37,31 +30,4 @@ export const mediaReleaseRouter = createTRPCRouter({
|
||||
})),
|
||||
);
|
||||
}),
|
||||
|
||||
subscribeToReleases: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"mediaRelease"> }>;
|
||||
releases: MediaRelease[];
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((releases) => {
|
||||
emit.next({
|
||||
integration,
|
||||
releases,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,14 +1,13 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import type { MediaRequest } from "@homarr/integrations/types";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaRequestsRouter = createTRPCRouter({
|
||||
@@ -22,22 +21,10 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
return results
|
||||
.flatMap(({ data, integration }) =>
|
||||
data.map((request) => ({
|
||||
@@ -56,32 +43,6 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
);
|
||||
});
|
||||
}),
|
||||
subscribeToLatestRequests: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
requests: MediaRequest[];
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((requests) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
requests,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
@@ -92,22 +53,10 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
return {
|
||||
stats: results.flatMap((result) => result.data.stats),
|
||||
users: results
|
||||
@@ -139,7 +88,6 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||
|
||||
const answerActions = {
|
||||
approve: (id: number) => integrationInstance.approveRequestAsync(id),
|
||||
@@ -147,6 +95,5 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
} as const;
|
||||
|
||||
await answerActions[input.answer](input.requestId);
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -24,46 +23,11 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {
|
||||
showOnlyPlaying: input.showOnlyPlaying,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
sessions: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeToCurrentStreams: publicProcedure
|
||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {
|
||||
showOnlyPlaying: input.showOnlyPlaying,
|
||||
});
|
||||
|
||||
const unsubscribe = innerHandler.subscribe((sessions) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
data: sessions,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaServerRequestHandler
|
||||
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
|
||||
.getDataAsync();
|
||||
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding";
|
||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||
import { paginatedSchema } from "@homarr/validation/common";
|
||||
|
||||
@@ -21,26 +18,11 @@ export const mediaTranscodingRouter = createTRPCRouter({
|
||||
pageOffset: (input.page - 1) * input.pageSize,
|
||||
pageSize: input.pageSize,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: ctx.integration.id,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
subscribeData: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.input(paginatedSchema.pick({ page: true, pageSize: true }))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{ integrationId: string; data: MediaTranscoding }>((emit) => {
|
||||
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
|
||||
pageOffset: (input.page - 1) * input.pageSize,
|
||||
pageSize: input.pageSize,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next({ integrationId: input.integrationId, data });
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
|
||||
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -16,21 +14,6 @@ export const minecraftRouter = createTRPCRouter({
|
||||
isBedrockServer: input.isBedrockServer,
|
||||
domain: input.domain,
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
||||
}),
|
||||
subscribeServerStatus: publicProcedure.input(serverStatusInputSchema).subscription(({ input }) => {
|
||||
return observable<MinecraftServerStatus>((emit) => {
|
||||
const innerHandler = minecraftServerStatusRequestHandler.handler({
|
||||
isBedrockServer: input.isBedrockServer,
|
||||
domain: input.domain,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next(data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
return await innerHandler.getDataAsync();
|
||||
}),
|
||||
});
|
||||
@@ -1,61 +1,26 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const networkControllerRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = networkControllerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = networkControllerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
summary: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeToSummary: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"networkController"> }>;
|
||||
summary: NetworkControllerSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = networkControllerRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
summary: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { Notification } from "@homarr/integrations";
|
||||
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
@@ -18,46 +13,18 @@ export const notificationsRouter = createTRPCRouter({
|
||||
getNotifications: publicProcedure
|
||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = notificationsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = notificationsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeNotifications: publicProcedure
|
||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"notifications"> }>;
|
||||
data: Notification[];
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next({
|
||||
integration,
|
||||
data,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
export const paperlessNgxRouter = createTRPCRouter({
|
||||
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
});
|
||||
@@ -77,30 +77,29 @@ export const releasesRouter = createTRPCRouter({
|
||||
|
||||
return await Promise.all(
|
||||
input.repositories.map(async (repository) => {
|
||||
const repositoryId = repository.id ?? repository.identifier;
|
||||
try {
|
||||
const useToken = allowedRepoIds === null || allowedRepoIds.has(repository.id);
|
||||
const useToken = allowedRepoIds === null || allowedRepoIds.has(repositoryId);
|
||||
const response = await releasesRequestHandler
|
||||
.handler({
|
||||
id: repository.id,
|
||||
id: repositoryId,
|
||||
provider: repository.provider,
|
||||
identifier: repository.identifier,
|
||||
versionRegex: formatVersionFilterRegex(repository.versionFilter),
|
||||
providerUrl: repository.providerUrl,
|
||||
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
|
||||
})
|
||||
.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
.getDataAsync();
|
||||
|
||||
return {
|
||||
id: repository.id,
|
||||
id: repositoryId,
|
||||
provider: repository.provider,
|
||||
timestamp: response.timestamp,
|
||||
...response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id: repository.id,
|
||||
id: repositoryId,
|
||||
provider: repository.provider,
|
||||
timestamp: new Date(),
|
||||
success: false as const,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -6,12 +5,14 @@ import type { Session } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { items, users } from "@homarr/db/schema";
|
||||
import type { ExtendedFeedEntry } from "@homarr/request-handler/rss-feeds";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../../widgets/src";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "rssFeed" });
|
||||
|
||||
const feedsInput = z.object({
|
||||
urls: z.array(z.string()).max(100),
|
||||
maximumAmountPosts: z.number(),
|
||||
@@ -23,41 +24,28 @@ export const rssFeedRouter = createTRPCRouter({
|
||||
? input.urls
|
||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||
|
||||
const rssFeeds = await Promise.all(
|
||||
const settled = await Promise.allSettled(
|
||||
urls.map(async (url) => {
|
||||
const innerHandler = rssFeedsRequestHandler.handler({
|
||||
url,
|
||||
count: input.maximumAmountPosts,
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
return await innerHandler.getDataAsync();
|
||||
}),
|
||||
);
|
||||
|
||||
return rssFeeds
|
||||
.flatMap((rssFeed) => rssFeed.data.entries)
|
||||
.slice(0, input.maximumAmountPosts)
|
||||
.sort((entryA, entryB) => {
|
||||
return settled
|
||||
.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value.data.entries;
|
||||
logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
|
||||
return [];
|
||||
})
|
||||
.toSorted((entryA, entryB) => {
|
||||
return entryA.published && entryB.published
|
||||
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
||||
: 0;
|
||||
});
|
||||
}),
|
||||
subscribeFeeds: publicProcedure.input(feedsInput).subscription(({ input }) => {
|
||||
// It is not necessary to check access to all feeds here, as the client will only subscribe to feeds which already exist
|
||||
return observable<{ url: string; entries: ExtendedFeedEntry[] }>((emit) => {
|
||||
const unsubscribes = input.urls.map((url) => {
|
||||
const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts });
|
||||
return innerHandler.subscribe((data) => {
|
||||
emit.next({ url, entries: data.entries });
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
});
|
||||
})
|
||||
.slice(0, input.maximumAmountPosts);
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
@@ -25,31 +24,9 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
subscribeEntityState: publicProcedure
|
||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.subscription(({ input, ctx }) => {
|
||||
return observable<{
|
||||
entityId: string;
|
||||
state: string;
|
||||
}>((emit) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, {
|
||||
entityId: input.entityId,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((state) => {
|
||||
emit.next({ state, entityId: input.entityId });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
switchEntity: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
@@ -64,9 +41,6 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
const client = await createIntegrationAsync(integration);
|
||||
const success = await client.triggerToggleAsync(input.entityId);
|
||||
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
await innerHandler.invalidateAsync();
|
||||
|
||||
return success;
|
||||
}),
|
||||
executeAutomation: protectedProcedure
|
||||
|
||||
@@ -1,54 +1,23 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
|
||||
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const speedtestTrackerRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeToDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
dashboard: SpeedtestTrackerDashboardData;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((dashboard) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
dashboard,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -18,6 +18,6 @@ export const stockPriceRouter = createTRPCRouter({
|
||||
timeRange: input.timeRange,
|
||||
timeInterval: input.timeInterval,
|
||||
});
|
||||
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await innerHandler.getDataAsync();
|
||||
}),
|
||||
});
|
||||
@@ -19,14 +19,7 @@ export const timetableRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const innerHandler = timetableGetTimetableRequestHandler.handler({
|
||||
baseUrl: input.baseUrl,
|
||||
stationId: input.stationId,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
searchStations: publicProcedure
|
||||
@@ -37,13 +30,7 @@ export const timetableRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const innerHandler = timetableSearchStationsRequestHandler.handler({
|
||||
baseUrl: input.baseUrl,
|
||||
query: input.query,
|
||||
});
|
||||
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
@@ -1,48 +1,22 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const tracearrRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
subscribeToDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "tracearr"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; dashboard: TracearrDashboardData; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((dashboard) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
dashboard,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
} from "@homarr/request-handler/umami";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "umami-router" });
|
||||
@@ -39,26 +39,22 @@ export const umamiRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventName: input.eventName,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventName: input.eventName,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
visitorStats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
visitorStats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getEventNames: publicProcedure
|
||||
@@ -67,7 +63,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -93,7 +89,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -123,7 +119,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -148,13 +144,13 @@ export const umamiRouter = createTRPCRouter({
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const sortedNames = [...input.eventNames].sort();
|
||||
const sortedNames = [...input.eventNames].toSorted();
|
||||
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventNames: sortedNames,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -176,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -185,21 +181,4 @@ export const umamiRouter = createTRPCRouter({
|
||||
return 0;
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<number>((emit) => {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((count) => {
|
||||
emit.next(count);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,50 +1,24 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { UpsSummary } from "@homarr/integrations/types";
|
||||
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const upsRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
subscribeSummaries: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((summaries) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
summaries,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,54 +1,23 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
|
||||
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const uptimeKumaRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeToDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
dashboard: UptimeKumaDashboardData;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((dashboard) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
dashboard,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { VpnSummary } from "@homarr/integrations/types";
|
||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createVpnIntegrationMiddleware = () =>
|
||||
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
|
||||
|
||||
export const vpnRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = vpnSummaryHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
getSummaries: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
@@ -29,31 +20,6 @@ export const vpnRouter = createTRPCRouter({
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
|
||||
summary: VpnSummary | null;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Weather } from "@homarr/request-handler/weather";
|
||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -14,16 +12,6 @@ const atLocationInput = z.object({
|
||||
export const weatherRouter = createTRPCRouter({
|
||||
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
||||
const handler = weatherRequestHandler.handler(input);
|
||||
return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data);
|
||||
}),
|
||||
subscribeAtLocation: publicProcedure.input(atLocationInput).subscription(({ input }) => {
|
||||
return observable<Weather>((emit) => {
|
||||
const handler = weatherRequestHandler.handler(input);
|
||||
const unsubscribe = handler.subscribe((data) => {
|
||||
emit.next(data);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
return await handler.getDataAsync().then((result) => result.data);
|
||||
}),
|
||||
});
|
||||
@@ -39,7 +39,7 @@ export const searchDuckDuckGoBangsAsync = async (input: {
|
||||
const queryTokenPrefix = normalizeBangToken(input.query);
|
||||
if (!queryTokenPrefix) return [];
|
||||
|
||||
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
|
||||
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getDataAsync();
|
||||
const startIndex = lowerBound(allBangs, queryTokenPrefix);
|
||||
const matches: DuckDuckGoBang[] = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
const logger = createLogger({ module: "settleIntegrations" });
|
||||
|
||||
interface IntegrationLike {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
integrations: TIntegration[],
|
||||
fn: (integration: TIntegration) => Promise<TResult>,
|
||||
): Promise<TResult[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
return settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return [result.value];
|
||||
const integration = integrations[index];
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Integration query failed",
|
||||
{ integrationId: integration?.id, integrationKind: integration?.kind },
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
@@ -10,59 +10,31 @@ import {
|
||||
isPersistableWidgetQueryKey,
|
||||
queryCacheDefaultStaleTimeMs,
|
||||
queryCacheMaxValueBytes,
|
||||
queryCacheRetentionMs,
|
||||
} from "@homarr/api/query-cache";
|
||||
|
||||
import { queryCacheRouter } from "../router/query-cache";
|
||||
|
||||
const redisMock = vi.hoisted(() => {
|
||||
const values = new Map<string, { value: string; expiresAt: number }>();
|
||||
const calls: {
|
||||
userId: string;
|
||||
boardId: string;
|
||||
key: string;
|
||||
ttlMs: number;
|
||||
maxValueBytes: number;
|
||||
}[] = [];
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
values,
|
||||
calls,
|
||||
createQueryCacheChannel: vi.fn(
|
||||
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
|
||||
calls.push(options);
|
||||
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
|
||||
|
||||
return {
|
||||
getAsync: async () => {
|
||||
const item = values.get(storageKey);
|
||||
if (!item) return null;
|
||||
if (Date.now() > item.expiresAt) {
|
||||
values.delete(storageKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
},
|
||||
setAsync: async (value: string) => {
|
||||
if (Buffer.byteLength(value, "utf8") > options.maxValueBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
values.set(storageKey, { value, expiresAt: Date.now() + options.ttlMs });
|
||||
return true;
|
||||
},
|
||||
removeAsync: async () => {
|
||||
values.delete(storageKey);
|
||||
},
|
||||
};
|
||||
setQueryCacheAsync: vi.fn(
|
||||
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
|
||||
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
|
||||
values.set(`${_userId}:${_boardId}`, value);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
|
||||
values.delete(`${_userId}:${_boardId}`);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
createQueryCacheChannel: redisMock.createQueryCacheChannel,
|
||||
setQueryCacheAsync: redisMock.setQueryCacheAsync,
|
||||
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
@@ -70,8 +42,8 @@ vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
redisMock.values.clear();
|
||||
redisMock.calls.length = 0;
|
||||
redisMock.createQueryCacheChannel.mockClear();
|
||||
redisMock.setQueryCacheAsync.mockClear();
|
||||
redisMock.removeQueryCacheAsync.mockClear();
|
||||
});
|
||||
|
||||
const createSession = (userId = createId()): Session => ({
|
||||
@@ -104,7 +76,7 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
});
|
||||
|
||||
describe("queryCacheRouter", () => {
|
||||
test("stores, reads, and removes data for a board viewer", async () => {
|
||||
test("stores and removes data for a board viewer", async () => {
|
||||
const db = createDb();
|
||||
const session = createSession();
|
||||
await insertUserAsync(db, session.user.id);
|
||||
@@ -112,14 +84,19 @@ describe("queryCacheRouter", () => {
|
||||
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" })).resolves.toEqual({
|
||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).resolves.toEqual({
|
||||
stored: true,
|
||||
});
|
||||
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
|
||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||
session.user.id,
|
||||
"board-1",
|
||||
"cached-value",
|
||||
expect.any(Number),
|
||||
queryCacheMaxValueBytes,
|
||||
);
|
||||
|
||||
await caller.removeItem({ boardId: "board-1", key: "query-key" });
|
||||
|
||||
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
|
||||
await caller.removeItem({ boardId: "board-1" });
|
||||
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
|
||||
});
|
||||
|
||||
test("does not touch Redis when board access is denied", async () => {
|
||||
@@ -127,32 +104,32 @@ describe("queryCacheRouter", () => {
|
||||
await insertUserAsync(db, "owner");
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
|
||||
|
||||
redisMock.createQueryCacheChannel.mockClear();
|
||||
redisMock.setQueryCacheAsync.mockClear();
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: createSession("other-user") });
|
||||
|
||||
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).rejects.toThrow("Board not found");
|
||||
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
|
||||
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
|
||||
expect(redisMock.setQueryCacheAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("scopes anonymous public-board cache entries by anonymous user and board", async () => {
|
||||
test("scopes anonymous public-board cache entries by anonymous user", async () => {
|
||||
const db = createDb();
|
||||
await insertUserAsync(db, "owner");
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
|
||||
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
|
||||
|
||||
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
|
||||
await caller.setItem({ boardId: "board-1", value: "cached-value" });
|
||||
|
||||
expect(redisMock.calls.at(-1)).toMatchObject({
|
||||
userId: "anonymous",
|
||||
boardId: "board-1",
|
||||
key: "query-key",
|
||||
ttlMs: queryCacheRetentionMs,
|
||||
maxValueBytes: queryCacheMaxValueBytes,
|
||||
});
|
||||
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||
"anonymous",
|
||||
"board-1",
|
||||
"cached-value",
|
||||
expect.any(Number),
|
||||
queryCacheMaxValueBytes,
|
||||
);
|
||||
});
|
||||
|
||||
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
|
||||
test("rejects oversized values", async () => {
|
||||
const db = createDb();
|
||||
const session = createSession("user-1");
|
||||
await insertUserAsync(db, session.user.id);
|
||||
@@ -161,34 +138,9 @@ describe("queryCacheRouter", () => {
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const value = "x".repeat(queryCacheMaxValueBytes + 1);
|
||||
|
||||
await expect(caller.setItem({ boardId: "board-1", key: "query-key", value })).resolves.toEqual({
|
||||
await expect(caller.setItem({ boardId: "board-1", value })).resolves.toEqual({
|
||||
stored: false,
|
||||
});
|
||||
expect(redisMock.calls.at(-1)).toMatchObject({
|
||||
userId: "user-1",
|
||||
boardId: "board-1",
|
||||
ttlMs: queryCacheRetentionMs,
|
||||
maxValueBytes: queryCacheMaxValueBytes,
|
||||
});
|
||||
});
|
||||
|
||||
test("expires values through the Redis TTL path", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
||||
const db = createDb();
|
||||
const session = createSession("user-1");
|
||||
await insertUserAsync(db, session.user.id);
|
||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: session.user.id, isPublic: false });
|
||||
|
||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
||||
|
||||
await caller.setItem({ boardId: "board-1", key: "query-key", value: "cached-value" });
|
||||
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBe("cached-value");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(queryCacheRetentionMs + 1);
|
||||
|
||||
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -95,8 +95,12 @@ export const createTRPCRouter = t.router;
|
||||
|
||||
export const isDemoMode = env.DEMO_MODE;
|
||||
|
||||
// The public "real demo" (demo + mock integrations) stays read-only, while preview
|
||||
// deployments run with DEMO_MODE + DEMO_READ_ONLY=false to allow mutations.
|
||||
export const isDemoReadOnly = env.DEMO_MODE && env.DEMO_READ_ONLY;
|
||||
|
||||
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
if (env.DEMO_MODE && type === "mutation") {
|
||||
if (isDemoReadOnly && type === "mutation") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Mutations are disabled in demo mode",
|
||||
@@ -105,7 +109,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
|
||||
/**
|
||||
* Public (unauthed) procedure
|
||||
@@ -116,6 +120,8 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
|
||||
*/
|
||||
export const publicProcedure = baseProcedure;
|
||||
|
||||
export const internalProcedure = t.procedure;
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user