Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96bb33299e | ||
|
|
39a1a3695e | ||
|
|
fbac0c8752 | ||
|
|
26c83d8853 | ||
|
|
4ff2c9a7fb | ||
|
|
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 | ||
|
|
46ae756d15 | ||
|
|
8b63799f1b | ||
|
|
60a69e31ef | ||
|
|
57ef0af4a2 | ||
|
|
597b5fbc8d | ||
|
|
5412577519 |
No files matched your search
@@ -33,6 +33,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
# The below comment is used to insert a new version with on-release.yml
|
# The below comment is used to insert a new version with on-release.yml
|
||||||
#NEXT_VERSION#
|
#NEXT_VERSION#
|
||||||
|
- 1.69.0
|
||||||
- 1.68.0
|
- 1.68.0
|
||||||
- 1.67.0
|
- 1.67.0
|
||||||
- 1.66.1
|
- 1.66.1
|
||||||
|
|||||||
@@ -163,65 +163,3 @@ jobs:
|
|||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm turbo build --affected
|
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"
|
|
||||||
@@ -77,3 +77,4 @@ skills-lock.json
|
|||||||
# docusaurus
|
# docusaurus
|
||||||
apps/docs/.docusaurus
|
apps/docs/.docusaurus
|
||||||
apps/docs/build
|
apps/docs/build
|
||||||
|
.pnpm-store
|
||||||
@@ -1,3 +1,42 @@
|
|||||||
|
## [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)
|
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -39,13 +39,26 @@ import { immichServerStatsWidget } from '../../widgets/immich-server-stats';
|
|||||||
'Scroll down to "API Keys"',
|
'Scroll down to "API Keys"',
|
||||||
'Click "New API Key"',
|
'Click "New API Key"',
|
||||||
'Enter a name, e.g. "Homarr"',
|
'Enter a name, e.g. "Homarr"',
|
||||||
(<span>
|
(
|
||||||
Select the following permissions:{" "}
|
|
||||||
<code>album.read</code>,{" "}
|
<span>
|
||||||
<code>user.read</code>,{" "}
|
|
||||||
<code>server.statistics</code>,{" "}
|
Select the following permissions:{" "}
|
||||||
<code>asset.download</code>
|
<code>album.statistics</code>,{" "}
|
||||||
</span>),
|
<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'
|
'Create the integration in Homarr and copy the API key to there'
|
||||||
]
|
]
|
||||||
}]} />
|
}]} />
|
||||||
@@ -32,19 +32,19 @@
|
|||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
"@tailwindcss/postcss": "^4.3.2",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||||
"leader-line-new": "^1.1.9",
|
"leader-line-new": "^1.1.9",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.16",
|
||||||
"posthog-docusaurus": "^2.0.5",
|
"posthog-docusaurus": "^2.0.5",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"random-word-slugs": "^0.1.7",
|
"random-word-slugs": "^0.1.7",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"swiper": "^12.2.0",
|
"swiper": "^12.2.0",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||||
|
|||||||
@@ -59,10 +59,11 @@
|
|||||||
"@modelcontextprotocol/sdk": "catalog:",
|
"@modelcontextprotocol/sdk": "catalog:",
|
||||||
"@scalar/api-reference-react": "catalog:",
|
"@scalar/api-reference-react": "catalog:",
|
||||||
"@tabler/icons-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": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-query-next-experimental": "catalog:",
|
"@tanstack/react-query-next-experimental": "catalog:",
|
||||||
|
"@tanstack/react-query-persist-client": "catalog:",
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/next": "catalog:",
|
"@trpc/next": "catalog:",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
|
|||||||
@@ -1,49 +1,32 @@
|
|||||||
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
|
import superjson from "superjson";
|
||||||
import { parse, stringify } from "superjson";
|
|
||||||
|
|
||||||
import { fetchApi } from "@homarr/api/client";
|
import { fetchApi } from "@homarr/api/client";
|
||||||
import {
|
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||||
getActiveQueryCacheBoardId,
|
|
||||||
isPersistableWidgetQueryKey,
|
|
||||||
queryCacheStoragePrefix,
|
|
||||||
} from "@homarr/api/query-cache";
|
|
||||||
|
|
||||||
const queryCacheStorage = {
|
const queryCacheStorage = {
|
||||||
async getItem(_key) {
|
getItem: (_key: string) => null as string | null,
|
||||||
return null;
|
async setItem(_key: string, value: string) {
|
||||||
},
|
|
||||||
async setItem(key, value) {
|
|
||||||
const boardId = getActiveQueryCacheBoardId();
|
const boardId = getActiveQueryCacheBoardId();
|
||||||
if (!boardId) return;
|
if (!boardId) return;
|
||||||
|
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||||
try {
|
console.warn("[query-cache] persist failed", error);
|
||||||
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
|
});
|
||||||
} catch {
|
|
||||||
// Query persistence is best-effort; failed writes should not affect widget rendering.
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async removeItem(key) {
|
async removeItem(_key: string) {
|
||||||
const boardId = getActiveQueryCacheBoardId();
|
const boardId = getActiveQueryCacheBoardId();
|
||||||
if (!boardId) return;
|
if (!boardId) return;
|
||||||
|
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||||
try {
|
console.warn("[query-cache] remove failed", error);
|
||||||
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
|
});
|
||||||
} catch {
|
|
||||||
// Query persistence is best-effort; failed removals should not affect widget rendering.
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
} satisfies AsyncStorage<string>;
|
};
|
||||||
|
|
||||||
export const createWidgetQueryPersister = () =>
|
export const createWidgetQueryPersister = () =>
|
||||||
experimental_createQueryPersister<string>({
|
createAsyncStoragePersister({
|
||||||
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
|
||||||
maxAge: Number.POSITIVE_INFINITY,
|
key: queryCacheStoragePrefix,
|
||||||
prefix: queryCacheStoragePrefix,
|
throttleTime: 2000,
|
||||||
refetchOnRestore: true,
|
serialize: (data) => superjson.stringify(data),
|
||||||
serialize: (query) => stringify(query),
|
deserialize: (data) => superjson.parse(data),
|
||||||
deserialize: (value) => parse<PersistedQuery>(value),
|
|
||||||
filters: {
|
|
||||||
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useState } 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 { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import {
|
import {
|
||||||
createWSClient,
|
createWSClient,
|
||||||
httpBatchStreamLink,
|
httpBatchStreamLink,
|
||||||
@@ -21,7 +22,13 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
|
|
||||||
import type { AppRouter } from "@homarr/api";
|
import type { AppRouter } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
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 { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
import { showWarningNotification } from "@homarr/notifications";
|
import { showWarningNotification } from "@homarr/notifications";
|
||||||
@@ -29,7 +36,6 @@ import { showWarningNotification } from "@homarr/notifications";
|
|||||||
import { createWidgetQueryPersister } from "./query-cache-persister";
|
import { createWidgetQueryPersister } from "./query-cache-persister";
|
||||||
|
|
||||||
const getWebSocketProtocol = () => {
|
const getWebSocketProtocol = () => {
|
||||||
// window is not defined on server side
|
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return "ws";
|
return "ws";
|
||||||
}
|
}
|
||||||
@@ -55,32 +61,36 @@ const wsClient = createWSClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TRPCReactProvider(props: PropsWithChildren) {
|
export function TRPCReactProvider(props: PropsWithChildren) {
|
||||||
const [queryPersister] = useState(() => createWidgetQueryPersister());
|
const [persister] = useState(() => createWidgetQueryPersister());
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(() => {
|
||||||
() =>
|
const client = new QueryClient({
|
||||||
new QueryClient({
|
defaultOptions: {
|
||||||
defaultOptions: {
|
queries: {
|
||||||
queries: {
|
staleTime: queryCacheDefaultStaleTimeMs,
|
||||||
persister: queryPersister.persisterFn,
|
gcTime: queryCacheDefaultGcTimeMs,
|
||||||
staleTime: queryCacheDefaultStaleTimeMs,
|
refetchOnWindowFocus: false,
|
||||||
},
|
refetchOnReconnect: false,
|
||||||
mutations: {
|
retry: 3,
|
||||||
onError(error) {
|
},
|
||||||
if (
|
mutations: {
|
||||||
error instanceof TRPCClientError &&
|
onError(error) {
|
||||||
error.data?.code === "FORBIDDEN" &&
|
if (
|
||||||
error.message === "Mutations are disabled in demo mode"
|
error instanceof TRPCClientError &&
|
||||||
) {
|
error.data?.code === "FORBIDDEN" &&
|
||||||
showWarningNotification({
|
error.message === "Mutations are disabled in demo mode"
|
||||||
title: "Demo mode",
|
) {
|
||||||
message: "This action is 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(() => {
|
const [trpcClient] = useState(() => {
|
||||||
return clientApi.createClient({
|
return clientApi.createClient({
|
||||||
@@ -98,9 +108,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
false: splitLink({
|
false: splitLink({
|
||||||
condition: ({ input }) => isNonJsonSerializable(input),
|
condition: ({ input }) => isNonJsonSerializable(input),
|
||||||
true: httpLink({
|
true: httpLink({
|
||||||
/**
|
|
||||||
* We don't want to transform the data here as we want to use form data
|
|
||||||
*/
|
|
||||||
transformer: {
|
transformer: {
|
||||||
serialize(object: unknown) {
|
serialize(object: unknown) {
|
||||||
return object;
|
return object;
|
||||||
@@ -115,7 +122,7 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
false: httpBatchStreamLink({
|
false: httpBatchStreamLink({
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: getTrpcUrl(),
|
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)"),
|
headers: createHeadersCallbackForSource("nextjs-react (json)"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -126,10 +133,21 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<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>
|
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,10 @@ import { TRPCError } from "@trpc/server";
|
|||||||
// Placed here because gridstack styles are used for board content
|
// Placed here because gridstack styles are used for board content
|
||||||
import "~/styles/gridstack.scss";
|
import "~/styles/gridstack.scss";
|
||||||
|
|
||||||
import type { DehydratedState } from "@tanstack/react-query";
|
|
||||||
import { dehydrate, HydrationBoundary } 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 { getQueryClient } from "@homarr/api/server";
|
||||||
import { IntegrationProvider } from "@homarr/auth/client";
|
import { IntegrationProvider } from "@homarr/auth/client";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
@@ -17,7 +17,7 @@ import { isNullOrWhitespace } from "@homarr/common";
|
|||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { getAllQueryCacheAsync } from "@homarr/redis";
|
import { getQueryCacheAsync } from "@homarr/redis";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
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 }) {
|
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
|
||||||
try {
|
try {
|
||||||
const entries = await getAllQueryCacheAsync(userId, boardId);
|
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||||
const queries: DehydratedState["queries"] = [];
|
if (!serialized) return null;
|
||||||
|
|
||||||
for (const serialized of Object.values(entries)) {
|
const persisted = superjson.parse<PersistedClient>(serialized);
|
||||||
try {
|
if (persisted.buster !== queryCacheBuster) return null;
|
||||||
const persisted = parse<PersistedEntry>(serialized);
|
if (!persisted?.clientState?.queries?.length) return null;
|
||||||
if (persisted.state.status !== "success") continue;
|
|
||||||
|
|
||||||
queries.push({
|
return <HydrationBoundary state={persisted.clientState} />;
|
||||||
state: persisted.state as DehydratedState["queries"][number]["state"],
|
} catch (error) {
|
||||||
queryKey: persisted.queryKey,
|
logger.warn(new ErrorWithMetadata("Failed to hydrate query cache", { userId, boardId }, { cause: error }));
|
||||||
queryHash: persisted.queryHash,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// skip malformed entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queries.length === 0) return null;
|
|
||||||
|
|
||||||
return <HydrationBoundary state={{ mutations: [], queries }} />;
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } 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) {
|
export function DockerTable({ initialData }: DockerTableProps) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDocker = useScopedI18n("docker");
|
const tDocker = useScopedI18n("docker");
|
||||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
const { data, isFetching, refetch } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||||
initialData,
|
initialData,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
});
|
});
|
||||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||||
const utils = clientApi.useUtils();
|
const mutate = useCallback(() => {
|
||||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
void refetch().then((result) => {
|
||||||
async onSuccess() {
|
if (result.isError) {
|
||||||
await utils.docker.getContainers.invalidate();
|
showErrorNotification({
|
||||||
showSuccessNotification({
|
title: tDocker("action.refresh.notification.error.title"),
|
||||||
title: tDocker("action.refresh.notification.success.title"),
|
message: tDocker("action.refresh.notification.error.message"),
|
||||||
message: tDocker("action.refresh.notification.success.message"),
|
});
|
||||||
});
|
} else {
|
||||||
},
|
showSuccessNotification({
|
||||||
onError() {
|
title: tDocker("action.refresh.notification.success.title"),
|
||||||
showErrorNotification({
|
message: tDocker("action.refresh.notification.success.message"),
|
||||||
title: tDocker("action.refresh.notification.error.title"),
|
});
|
||||||
message: tDocker("action.refresh.notification.error.message"),
|
}
|
||||||
});
|
});
|
||||||
},
|
}, [refetch, tDocker]);
|
||||||
});
|
|
||||||
|
|
||||||
const containers = data?.containers ?? [];
|
const containers = data?.containers ?? [];
|
||||||
|
|
||||||
@@ -207,7 +205,12 @@ export function DockerTable({ initialData }: DockerTableProps) {
|
|||||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||||
renderTopToolbarCustomActions: () => (
|
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")}
|
{tDocker("action.refresh.label")}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { Button, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
interface DisableUserButtonProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
isSelf?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisableUserButton = ({ user, isSelf = false }: DisableUserButtonProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const changeDisabledMutation = clientApi.user.changeDisabled.useMutation();
|
||||||
|
const isDisabled = user.disabled;
|
||||||
|
|
||||||
|
const getSuccessMessage = () => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return {
|
||||||
|
title: t("user.action.enable.success", { defaultValue: "User enabled" }),
|
||||||
|
message: t("user.action.enable.successDescription", { defaultValue: "User has been enabled" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: t("user.action.disable.success", { defaultValue: "User disabled" }),
|
||||||
|
message: t("user.action.disable.successDescription", { defaultValue: "User has been disabled" }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (isSelf) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await changeDisabledMutation.mutateAsync({
|
||||||
|
userId: user.id,
|
||||||
|
disabled: !isDisabled,
|
||||||
|
});
|
||||||
|
showSuccessNotification(getSuccessMessage());
|
||||||
|
} catch {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("common.notification.update.error"),
|
||||||
|
message: t("common.notification.update.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTooltipLabel = () => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return t("user.action.enable.description", { defaultValue: "Enable this user" });
|
||||||
|
}
|
||||||
|
return t("user.action.disable.description", { defaultValue: "Disable this user" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonLabel = () => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return t("user.action.enable.label", { defaultValue: "Enable user" });
|
||||||
|
}
|
||||||
|
return t("user.action.disable.label", { defaultValue: "Disable user" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonColor = () => (isDisabled ? "green" : "yellow");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
isSelf
|
||||||
|
? t("user.action.disableSelf.disabled", { defaultValue: "Cannot disable your own account" })
|
||||||
|
: getTooltipLabel()
|
||||||
|
}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={handleToggle}
|
||||||
|
loading={isPending || changeDisabledMutation.isPending}
|
||||||
|
color={getButtonColor()}
|
||||||
|
variant="light"
|
||||||
|
disabled={isSelf}
|
||||||
|
>
|
||||||
|
{getButtonLabel()}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
|||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { canAccessUserEditPage } from "../access";
|
import { canAccessUserEditPage } from "../access";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
|
import { DisableUserButton } from "./_components/_disable-user-button";
|
||||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { ResetTours } from "./_components/_reset-tours";
|
import { ResetTours } from "./_components/_reset-tours";
|
||||||
@@ -61,6 +62,19 @@ export default async function EditUserPage(props: Props) {
|
|||||||
const searchEngines = await api.searchEngine.getSelectable();
|
const searchEngines = await api.searchEngine.getSelectable();
|
||||||
const isSelf = session?.user.id === user.id;
|
const isSelf = session?.user.id === user.id;
|
||||||
const isCredentialsUser = user.provider === "credentials";
|
const isCredentialsUser = user.provider === "credentials";
|
||||||
|
const isDisabled = user.disabled;
|
||||||
|
|
||||||
|
const getDisableItemLabel = () => {
|
||||||
|
if (isDisabled) return t("user.action.enable.label");
|
||||||
|
return t("user.action.disable.label");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisableItemDescription = () => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return t("management.page.user.action.enable.description", { defaultValue: "Re-enable access for this user" });
|
||||||
|
}
|
||||||
|
return t("management.page.user.action.disable.description", { defaultValue: "Prevent this user from logging in" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -96,6 +110,13 @@ export default async function EditUserPage(props: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
|
{!isSelf && (
|
||||||
|
<DangerZoneItem
|
||||||
|
label={getDisableItemLabel()}
|
||||||
|
description={getDisableItemDescription()}
|
||||||
|
action={<DisableUserButton user={user} isSelf={isSelf} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
label={t("user.action.delete.label")}
|
label={t("user.action.delete.label")}
|
||||||
description={t("user.action.delete.description")}
|
description={t("user.action.delete.description")}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Anchor, Group, Text, Tooltip } from "@mantine/core";
|
import { Anchor, Badge, Group, Text, Tooltip } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
import { MantineReactTable } from "mantine-react-table";
|
import { MantineReactTable } from "mantine-react-table";
|
||||||
@@ -52,6 +52,15 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
|
|||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "disabled",
|
||||||
|
header: t("user.field.status.label", { defaultValue: "Status" }),
|
||||||
|
size: 100,
|
||||||
|
Cell: ({ row }) => {
|
||||||
|
if (!row.original.disabled) return null;
|
||||||
|
return <Badge color="red">{t("user.field.status.disabled", { defaultValue: "Disabled" })}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from "react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Badge, Card } from "@mantine/core";
|
import { Badge, Card } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
@@ -105,12 +105,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
updateItemOptions({ itemId: item.id, newOptions });
|
updateItemOptions({ itemId: item.id, newOptions });
|
||||||
const widgetSupportsIntegrations =
|
const widgetSupportsIntegrations =
|
||||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
onReset={reset}
|
onReset={() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||||
<>
|
<>
|
||||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MutableRefObject, ReactNode } from "react";
|
import type { MutableRefObject, ReactNode } from "react";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Group, Menu, Switch } from "@mantine/core";
|
import { Group, Menu, Switch, Text } from "@mantine/core";
|
||||||
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
|
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -45,6 +47,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||||
const { gridstack } = useSectionContext().refs;
|
const { gridstack } = useSectionContext().refs;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
|
||||||
|
const handleRefetch = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
|
||||||
|
}, [queryClient, widgetQueryKey]);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
|
||||||
@@ -214,7 +222,15 @@ 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 closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
{tMenu("refresh")}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
closeMenuOnClick
|
closeMenuOnClick
|
||||||
leftSection={<IconSettings size={16} />}
|
leftSection={<IconSettings size={16} />}
|
||||||
@@ -248,3 +264,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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`;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { invalidateUpdateCheckerCacheAsync } from "./invalidate-update-checker-cache";
|
|
||||||
import { cleanupSessionsAsync } from "./session-cleanup";
|
import { cleanupSessionsAsync } from "./session-cleanup";
|
||||||
|
import { warmUpdateCheckerAsync } from "./warm-update-checker";
|
||||||
|
|
||||||
export async function onStartAsync() {
|
export async function onStartAsync() {
|
||||||
await cleanupSessionsAsync();
|
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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.68.1",
|
"version": "1.69.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ const logger = createLogger({ module: "analytics" });
|
|||||||
|
|
||||||
type AnalyticsResult = "sent" | "disabled" | "failed";
|
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 (
|
const getOrCreateInstanceId = async (
|
||||||
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
|
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const queryCacheRetentionMs = 1000 * 60 * 60 * 24;
|
export const queryCacheDefaultStaleTimeMs = 0;
|
||||||
export const queryCacheDefaultStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheDefaultRefetchIntervalMs = 30_000;
|
||||||
|
export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24;
|
||||||
export const queryCacheMaxValueBytes = 1024 * 1024;
|
export const queryCacheMaxValueBytes = 1024 * 1024;
|
||||||
export const queryCacheStoragePrefix = "homarr-widget-query";
|
export const queryCacheStoragePrefix = "homarr-widget-query";
|
||||||
|
export const queryCacheBuster = "v2-superjson";
|
||||||
|
|
||||||
let activeQueryCacheBoardId: string | null = null;
|
let activeQueryCacheBoardId: string | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
.concat(dockerMiddleware())
|
.concat(dockerMiddleware())
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||||
const result = await innerHandler.getCachedOrUpdatedDataAsync({
|
const result = await innerHandler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, timestamp } = result;
|
const { data, timestamp } = result;
|
||||||
|
|
||||||
@@ -36,26 +34,6 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
timestamp,
|
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
|
startAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.meta({
|
.meta({
|
||||||
@@ -73,9 +51,6 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
await container.start();
|
await container.start();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
|
||||||
await innerHandler.invalidateAsync();
|
|
||||||
}),
|
}),
|
||||||
stopAll: permissionRequiredProcedure
|
stopAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -94,9 +69,6 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
await container.stop();
|
await container.stop();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
|
||||||
await innerHandler.invalidateAsync();
|
|
||||||
}),
|
}),
|
||||||
restartAll: permissionRequiredProcedure
|
restartAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -116,9 +88,6 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
await container.restart();
|
await container.restart();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
|
||||||
await innerHandler.invalidateAsync();
|
|
||||||
}),
|
}),
|
||||||
removeAll: permissionRequiredProcedure
|
removeAll: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
@@ -138,9 +107,6 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
await container.remove();
|
await container.remove();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
|
||||||
await innerHandler.invalidateAsync();
|
|
||||||
}),
|
}),
|
||||||
logs: permissionRequiredProcedure
|
logs: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export const iconsRouter = createTRPCRouter({
|
|||||||
columns: { id: true, name: true, url: true },
|
columns: { id: true, name: true, url: true },
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
orderBy: (table, { asc, sql }) => {
|
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 svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||||
const nameAsc = asc(table.name);
|
const nameAsc = asc(table.name);
|
||||||
if (term.length === 0) {
|
if (term.length === 0) {
|
||||||
|
|||||||
@@ -2,53 +2,41 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { eq } from "@homarr/db";
|
import { eq } from "@homarr/db";
|
||||||
import { boards } from "@homarr/db/schema";
|
import { boards } from "@homarr/db/schema";
|
||||||
import { createQueryCacheChannel } from "@homarr/redis";
|
import { removeQueryCacheAsync, setQueryCacheAsync } from "@homarr/redis";
|
||||||
|
|
||||||
import { queryCacheMaxValueBytes, queryCacheRetentionMs } from "../query-cache";
|
import { queryCacheDefaultGcTimeMs, queryCacheMaxValueBytes } from "../query-cache";
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, internalProcedure } from "../trpc";
|
||||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||||
|
|
||||||
const queryCacheKeyInput = z.object({
|
const queryCacheBoardInput = z.object({
|
||||||
boardId: z.string().min(1),
|
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),
|
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) => {
|
const validateBoardAccessAsync = async (ctx: Parameters<typeof throwIfActionForbiddenAsync>[0], boardId: string) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, boardId), "view");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queryCacheRouter = createTRPCRouter({
|
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);
|
await validateBoardAccessAsync(ctx, input.boardId);
|
||||||
|
const userId = ctx.session?.user.id ?? "anonymous";
|
||||||
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");
|
|
||||||
return {
|
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);
|
await validateBoardAccessAsync(ctx, input.boardId);
|
||||||
|
const userId = ctx.session?.user.id ?? "anonymous";
|
||||||
const channel = createChannel(input, ctx.session?.user.id ?? "anonymous");
|
await removeQueryCacheAsync(userId, input.boardId);
|
||||||
await channel.removeAsync();
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -23,28 +23,12 @@ vi.mock("@homarr/request-handler/docker", () => ({
|
|||||||
},
|
},
|
||||||
dockerContainersRequestHandler: {
|
dockerContainersRequestHandler: {
|
||||||
handler: () => ({
|
handler: () => ({
|
||||||
getCachedOrUpdatedDataAsync: async () => {
|
getDataAsync: async () => {
|
||||||
return await Promise.resolve({ containers: [] });
|
return await Promise.resolve({ data: [], timestamp: new Date() });
|
||||||
},
|
|
||||||
invalidateAsync: async () => {
|
|
||||||
return await Promise.resolve();
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
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", () => ({
|
vi.mock("@homarr/docker/env", () => ({
|
||||||
env: {
|
env: {
|
||||||
ENABLE_DOCKER: true,
|
ENABLE_DOCKER: true,
|
||||||
@@ -67,12 +51,10 @@ const validInputs: {
|
|||||||
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
|
||||||
} = {
|
} = {
|
||||||
getContainers: undefined,
|
getContainers: undefined,
|
||||||
subscribeContainers: undefined,
|
|
||||||
startAll: { ids: ["1"] },
|
startAll: { ids: ["1"] },
|
||||||
stopAll: { ids: ["1"] },
|
stopAll: { ids: ["1"] },
|
||||||
restartAll: { ids: ["1"] },
|
restartAll: { ids: ["1"] },
|
||||||
removeAll: { ids: ["1"] },
|
removeAll: { ids: ["1"] },
|
||||||
invalidate: undefined,
|
|
||||||
logs: { id: "1", tail: 200 },
|
logs: { id: "1", tail: 200 },
|
||||||
subscribeLogs: { id: "1", tail: 200 },
|
subscribeLogs: { id: "1", tail: 200 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const updateCheckerRouter = createTRPCRouter({
|
|||||||
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
getAvailableUpdates: permissionRequiredProcedure.requiresPermission("admin").query(async () => {
|
||||||
try {
|
try {
|
||||||
const handler = updateCheckerRequestHandler.handler({});
|
const handler = updateCheckerRequestHandler.handler({});
|
||||||
const data = await handler.getCachedOrUpdatedDataAsync({});
|
const data = await handler.getDataAsync();
|
||||||
return data.data.availableUpdates;
|
return data.data.availableUpdates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(new Error("Failed to get available updates", { cause: error }));
|
logger.error(new Error("Failed to get available updates", { cause: error }));
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
disabled: true,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -252,6 +253,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -341,6 +343,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
image: true,
|
image: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
|
disabled: true,
|
||||||
homeBoardId: true,
|
homeBoardId: true,
|
||||||
mobileHomeBoardId: true,
|
mobileHomeBoardId: true,
|
||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
@@ -377,6 +380,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
image: true,
|
image: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
|
disabled: true,
|
||||||
homeBoardId: true,
|
homeBoardId: true,
|
||||||
mobileHomeBoardId: true,
|
mobileHomeBoardId: true,
|
||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
@@ -777,6 +781,45 @@ export const userRouter = createTRPCRouter({
|
|||||||
completedBoardTour: user?.completedBoardTour ?? false,
|
completedBoardTour: user?.completedBoardTour ?? false,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
changeDisabled: permissionRequiredProcedure
|
||||||
|
.requiresPermission("admin")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
disabled: z.boolean(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.void())
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/api/users/disabled",
|
||||||
|
tags: ["users"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const dbUser = await ctx.db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: eq(users.id, input.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
disabled: input.disabled,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof userBaseCreateSchema>, "groupIds">) => {
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
|
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
|
||||||
|
|
||||||
const { data } = await handler.getCachedOrUpdatedDataAsync({
|
const { data } = await handler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -57,9 +55,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||||
|
|
||||||
const { data } = await handler.getCachedOrUpdatedDataAsync({
|
const { data } = await handler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
@@ -79,9 +75,6 @@ export const anchorNotesRouter = createTRPCRouter({
|
|||||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const noteHandler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
|
||||||
await noteHandler.invalidateAsync();
|
|
||||||
|
|
||||||
return updatedNote;
|
return updatedNote;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResponseError && error.statusCode === 400) {
|
if (error instanceof ResponseError && error.statusCode === 400) {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
import { sendPingRequestAsync } from "@homarr/ping";
|
import { sendPingRequestAsync } from "@homarr/ping";
|
||||||
import { pingChannel, pingUrlChannel } from "@homarr/redis";
|
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
import { AppRepository } from "../app";
|
import { AppRepository } from "../app";
|
||||||
@@ -45,55 +43,4 @@ export const appRouter = createTRPCRouter({
|
|||||||
...pingResult,
|
...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 { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||||
|
|
||||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
@@ -11,29 +8,11 @@ export const archiveTeamWarriorRouter = createTRPCRouter({
|
|||||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||||
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data, timestamp } = await handler.getDataAsync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: data,
|
status: data,
|
||||||
updatedAt: timestamp,
|
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 = {
|
const fetchByKind = {
|
||||||
navidrome: async () => {
|
navidrome: async () => {
|
||||||
const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
|
const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
|
||||||
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
|
return (await handler.getDataAsync()).data;
|
||||||
},
|
},
|
||||||
audiobookshelf: async () => {
|
audiobookshelf: async () => {
|
||||||
const handler = audiobookshelfRequestHandler.handler(
|
const handler = audiobookshelfRequestHandler.handler(
|
||||||
{ ...ctx.integration, kind: "audiobookshelf" as const },
|
{ ...ctx.integration, kind: "audiobookshelf" as const },
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
|
return (await handler.getDataAsync()).data;
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
|
||||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
|
||||||
import {
|
import {
|
||||||
beszelAlertsRequestHandler,
|
beszelAlertsRequestHandler,
|
||||||
beszelStatsRequestHandler,
|
beszelStatsRequestHandler,
|
||||||
beszelSystemsRequestHandler,
|
beszelSystemsRequestHandler,
|
||||||
} from "@homarr/request-handler/beszel";
|
} from "@homarr/request-handler/beszel";
|
||||||
import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
|
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
@@ -33,9 +29,7 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
const settled = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
@@ -71,30 +65,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
return results;
|
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
|
getAlerts: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -125,13 +95,17 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
maxHistoryItems: input.maxHistoryItems,
|
maxHistoryItems: input.maxHistoryItems,
|
||||||
});
|
});
|
||||||
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
|
const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||||
const [alertsResult, systemsResult] = await Promise.all([
|
const [alertsSettled, systemsSettled] = await Promise.allSettled([
|
||||||
alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
|
alertsHandler.getDataAsync(),
|
||||||
systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
|
systemsHandler.getDataAsync(),
|
||||||
]);
|
]);
|
||||||
|
if (alertsSettled.status === "rejected") throw alertsSettled.reason;
|
||||||
|
const alertsResult = alertsSettled.value;
|
||||||
const systemNameMap: Record<string, string> = {};
|
const systemNameMap: Record<string, string> = {};
|
||||||
for (const system of systemsResult.data) {
|
if (systemsSettled.status === "fulfilled") {
|
||||||
systemNameMap[system.id] = system.name;
|
for (const system of systemsSettled.value.data) {
|
||||||
|
systemNameMap[system.id] = system.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
@@ -170,39 +144,6 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
return results;
|
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
|
getSystemStats: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -237,7 +178,7 @@ export const beszelRouter = createTRPCRouter({
|
|||||||
timePeriod: input.timePeriod,
|
timePeriod: input.timePeriod,
|
||||||
includeDocker: input.includeDocker,
|
includeDocker: input.includeDocker,
|
||||||
});
|
});
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
logger.debug("getSystemStats completed", {
|
logger.debug("getSystemStats completed", {
|
||||||
userId: ctx.session?.user?.id,
|
userId: ctx.session?.user?.id,
|
||||||
integrationId: integration.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 { z } from "zod/v4";
|
||||||
|
|
||||||
import type { Modify } from "@homarr/common/types";
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
|
||||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||||
|
|
||||||
@@ -39,9 +34,7 @@ export const calendarRouter = createTRPCRouter({
|
|||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
||||||
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
|
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
const { data } = await innerHandler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events: data,
|
events: data,
|
||||||
@@ -72,39 +65,4 @@ export const calendarRouter = createTRPCRouter({
|
|||||||
return [];
|
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 { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const coolifyRouter = createTRPCRouter({
|
export const coolifyRouter = createTRPCRouter({
|
||||||
getInstancesInfo: publicProcedure
|
getInstancesInfo: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "coolify"))
|
.concat(createManyIntegrationMiddleware("query", "coolify"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = coolifyRequestHandler.handler(integration, {});
|
||||||
const innerHandler = coolifyRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
instanceInfo: data,
|
instanceInfo: data,
|
||||||
updatedAt: timestamp,
|
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());
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
import { z } from "zod/v4";
|
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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
|
||||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const dnsHoleRouter = createTRPCRouter({
|
export const dnsHoleRouter = createTRPCRouter({
|
||||||
@@ -23,50 +19,11 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
|
||||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
return {
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||||
forceUpdate: false,
|
summary: data,
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -83,12 +40,6 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx: { integration } }) => {
|
.mutation(async ({ ctx: { integration } }) => {
|
||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.enableAsync();
|
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
|
disable: protectedProcedure
|
||||||
@@ -108,11 +59,5 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
await client.disableAsync(input.duration);
|
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 { 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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
|
||||||
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
||||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
||||||
@@ -28,52 +24,12 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
|
return {
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||||
forceUpdate: false,
|
data,
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type {
|
|
||||||
FirewallCpuSummary,
|
|
||||||
FirewallInterfacesSummary,
|
|
||||||
FirewallMemorySummary,
|
|
||||||
FirewallVersionSummary,
|
|
||||||
} from "@homarr/integrations";
|
|
||||||
import {
|
import {
|
||||||
firewallCpuRequestHandler,
|
firewallCpuRequestHandler,
|
||||||
firewallInterfacesRequestHandler,
|
firewallInterfacesRequestHandler,
|
||||||
@@ -18,198 +7,31 @@ import {
|
|||||||
} from "@homarr/request-handler/firewall";
|
} from "@homarr/request-handler/firewall";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
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({
|
export const firewallRouter = createTRPCRouter({
|
||||||
getFirewallCpuStatus: publicProcedure
|
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")))
|
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
|
||||||
.query(async ({ ctx }) => {
|
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
|
||||||
const results = await Promise.all(
|
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
|
|
||||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
|
||||||
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
|
|
||||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const healthMonitoringRouter = createTRPCRouter({
|
export const healthMonitoringRouter = createTRPCRouter({
|
||||||
@@ -20,48 +17,13 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
return {
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
integrationId: integration.id,
|
||||||
forceUpdate: false,
|
integrationName: integration.name,
|
||||||
});
|
healthInfo: data,
|
||||||
|
updatedAt: timestamp,
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -76,24 +38,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
const { data } = await innerHandler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
return data;
|
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({
|
export const immichRouter = createTRPCRouter({
|
||||||
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||||
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
|
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
|
||||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const data = await innerHandler.getDataAsync();
|
||||||
return data.data;
|
return data.data;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ export const immichRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
|
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
|
||||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const data = await innerHandler.getDataAsync();
|
||||||
return data.data;
|
return data.data;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
|
||||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
|
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
|
||||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const data = await innerHandler.getDataAsync();
|
||||||
return data.data;
|
return data.data;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import type { Indexer } from "@homarr/integrations/types";
|
|
||||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||||
@@ -17,39 +16,13 @@ export const indexerManagerRouter = createTRPCRouter({
|
|||||||
getIndexersStatus: publicProcedure
|
getIndexersStatus: publicProcedure
|
||||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
|
||||||
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
|
const { data: indexers } = await innerHandler.getDataAsync();
|
||||||
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
indexers,
|
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { MediaRelease } from "@homarr/integrations/types";
|
|
||||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const mediaReleaseRouter = createTRPCRouter({
|
export const mediaReleaseRouter = createTRPCRouter({
|
||||||
getMediaReleases: publicProcedure
|
getMediaReleases: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integration: {
|
integration: {
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
name: integration.name,
|
name: integration.name,
|
||||||
kind: integration.kind,
|
kind: integration.kind,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
},
|
},
|
||||||
releases: data,
|
releases: data,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
return results.flatMap((result) =>
|
return results.flatMap((result) =>
|
||||||
result.releases.map((release) => ({
|
result.releases.map((release) => ({
|
||||||
...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 { z } from "zod/v4";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||||
import type { MediaRequest } from "@homarr/integrations/types";
|
|
||||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const mediaRequestsRouter = createTRPCRouter({
|
export const mediaRequestsRouter = createTRPCRouter({
|
||||||
@@ -22,22 +21,10 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const { data } = await mediaRequestListRequestHandler.handler(integration, {}).getDataAsync();
|
||||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
});
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
integration: {
|
|
||||||
id: integration.id,
|
|
||||||
name: integration.name,
|
|
||||||
kind: integration.kind,
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return results
|
return results
|
||||||
.flatMap(({ data, integration }) =>
|
.flatMap(({ data, integration }) =>
|
||||||
data.map((request) => ({
|
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
|
getStats: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -92,22 +53,10 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||||
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
|
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
});
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
integration: {
|
|
||||||
id: integration.id,
|
|
||||||
name: integration.name,
|
|
||||||
kind: integration.kind,
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
stats: results.flatMap((result) => result.data.stats),
|
stats: results.flatMap((result) => result.data.stats),
|
||||||
users: results
|
users: results
|
||||||
@@ -139,7 +88,6 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx: { integration }, input }) => {
|
.mutation(async ({ ctx: { integration }, input }) => {
|
||||||
const integrationInstance = await createIntegrationAsync(integration);
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
|
||||||
|
|
||||||
const answerActions = {
|
const answerActions = {
|
||||||
approve: (id: number) => integrationInstance.approveRequestAsync(id),
|
approve: (id: number) => integrationInstance.approveRequestAsync(id),
|
||||||
@@ -147,6 +95,5 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await answerActions[input.answer](input.requestId);
|
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 { z } from "zod/v4";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { StreamSession } from "@homarr/integrations";
|
|
||||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||||
@@ -24,46 +23,11 @@ export const mediaServerRouter = createTRPCRouter({
|
|||||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const { data } = await mediaServerRequestHandler
|
||||||
const innerHandler = mediaServerRequestHandler.handler(integration, {
|
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
|
||||||
showOnlyPlaying: input.showOnlyPlaying,
|
.getDataAsync();
|
||||||
});
|
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
|
||||||
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();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { MediaTranscoding } from "@homarr/request-handler/media-transcoding";
|
|
||||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||||
import { paginatedSchema } from "@homarr/validation/common";
|
import { paginatedSchema } from "@homarr/validation/common";
|
||||||
|
|
||||||
@@ -21,26 +18,11 @@ export const mediaTranscodingRouter = createTRPCRouter({
|
|||||||
pageOffset: (input.page - 1) * input.pageSize,
|
pageOffset: (input.page - 1) * input.pageSize,
|
||||||
pageSize: input.pageSize,
|
pageSize: input.pageSize,
|
||||||
});
|
});
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data } = await innerHandler.getDataAsync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: ctx.integration.id,
|
integrationId: ctx.integration.id,
|
||||||
data,
|
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 { z } from "zod/v4";
|
||||||
|
|
||||||
import type { MinecraftServerStatus } from "@homarr/request-handler/minecraft-server-status";
|
|
||||||
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
|
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
@@ -16,21 +14,6 @@ export const minecraftRouter = createTRPCRouter({
|
|||||||
isBedrockServer: input.isBedrockServer,
|
isBedrockServer: input.isBedrockServer,
|
||||||
domain: input.domain,
|
domain: input.domain,
|
||||||
});
|
});
|
||||||
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
return await innerHandler.getDataAsync();
|
||||||
}),
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
|
||||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const networkControllerRouter = createTRPCRouter({
|
export const networkControllerRouter = createTRPCRouter({
|
||||||
summary: publicProcedure
|
summary: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = networkControllerRequestHandler.handler(integration, {});
|
||||||
const innerHandler = networkControllerRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integration: {
|
integration: {
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
name: integration.name,
|
name: integration.name,
|
||||||
kind: integration.kind,
|
kind: integration.kind,
|
||||||
},
|
},
|
||||||
summary: data,
|
summary: data,
|
||||||
updatedAt: timestamp,
|
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { Notification } from "@homarr/integrations";
|
|
||||||
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
||||||
|
|
||||||
import type { IntegrationAction } from "../../middlewares/integration";
|
import type { IntegrationAction } from "../../middlewares/integration";
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
||||||
@@ -18,46 +13,18 @@ export const notificationsRouter = createTRPCRouter({
|
|||||||
getNotifications: publicProcedure
|
getNotifications: publicProcedure
|
||||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = notificationsRequestHandler.handler(integration, {});
|
||||||
const innerHandler = notificationsRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integration: {
|
integration: {
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
name: integration.name,
|
name: integration.name,
|
||||||
kind: integration.kind,
|
kind: integration.kind,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
},
|
},
|
||||||
data,
|
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();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
|||||||
export const paperlessNgxRouter = createTRPCRouter({
|
export const paperlessNgxRouter = createTRPCRouter({
|
||||||
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
|
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
|
||||||
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
|
||||||
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const data = await innerHandler.getDataAsync();
|
||||||
return data.data;
|
return data.data;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -89,9 +89,7 @@ export const releasesRouter = createTRPCRouter({
|
|||||||
providerUrl: repository.providerUrl,
|
providerUrl: repository.providerUrl,
|
||||||
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
|
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
|
||||||
})
|
})
|
||||||
.getCachedOrUpdatedDataAsync({
|
.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: repositoryId,
|
id: repositoryId,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
import SuperJSON from "superjson";
|
import SuperJSON from "superjson";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -6,12 +5,14 @@ import type { Session } from "@homarr/auth";
|
|||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { eq } from "@homarr/db";
|
import { eq } from "@homarr/db";
|
||||||
import { items, users } from "@homarr/db/schema";
|
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 { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../../../widgets/src";
|
import type { WidgetComponentProps } from "../../../../widgets/src";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "rssFeed" });
|
||||||
|
|
||||||
const feedsInput = z.object({
|
const feedsInput = z.object({
|
||||||
urls: z.array(z.string()).max(100),
|
urls: z.array(z.string()).max(100),
|
||||||
maximumAmountPosts: z.number(),
|
maximumAmountPosts: z.number(),
|
||||||
@@ -23,41 +24,28 @@ export const rssFeedRouter = createTRPCRouter({
|
|||||||
? input.urls
|
? input.urls
|
||||||
: await restrictUrlsAsync(ctx.db, input.urls);
|
: await restrictUrlsAsync(ctx.db, input.urls);
|
||||||
|
|
||||||
const rssFeeds = await Promise.all(
|
const settled = await Promise.allSettled(
|
||||||
urls.map(async (url) => {
|
urls.map(async (url) => {
|
||||||
const innerHandler = rssFeedsRequestHandler.handler({
|
const innerHandler = rssFeedsRequestHandler.handler({
|
||||||
url,
|
url,
|
||||||
count: input.maximumAmountPosts,
|
count: input.maximumAmountPosts,
|
||||||
});
|
});
|
||||||
return await innerHandler.getCachedOrUpdatedDataAsync({
|
return await innerHandler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return rssFeeds
|
return settled
|
||||||
.flatMap((rssFeed) => rssFeed.data.entries)
|
.flatMap((result, index) => {
|
||||||
.slice(0, input.maximumAmountPosts)
|
if (result.status === "fulfilled") return result.value.data.entries;
|
||||||
.sort((entryA, entryB) => {
|
logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.toSorted((entryA, entryB) => {
|
||||||
return entryA.published && entryB.published
|
return entryA.published && entryB.published
|
||||||
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
? new Date(entryB.published).getTime() - new Date(entryA.published).getTime()
|
||||||
: 0;
|
: 0;
|
||||||
});
|
})
|
||||||
}),
|
.slice(0, input.maximumAmountPosts);
|
||||||
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());
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { observable } from "@trpc/server/observable";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
@@ -25,31 +24,9 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||||
.query(async ({ ctx: { integration }, input }) => {
|
.query(async ({ ctx: { integration }, input }) => {
|
||||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
|
const { data } = await innerHandler.getDataAsync();
|
||||||
forceUpdate: false,
|
|
||||||
});
|
|
||||||
return data;
|
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
|
switchEntity: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -64,9 +41,6 @@ export const smartHomeRouter = createTRPCRouter({
|
|||||||
const client = await createIntegrationAsync(integration);
|
const client = await createIntegrationAsync(integration);
|
||||||
const success = await client.triggerToggleAsync(input.entityId);
|
const success = await client.triggerToggleAsync(input.entityId);
|
||||||
|
|
||||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
|
||||||
await innerHandler.invalidateAsync();
|
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}),
|
}),
|
||||||
executeAutomation: protectedProcedure
|
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 { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const speedtestTrackerRouter = createTRPCRouter({
|
export const speedtestTrackerRouter = createTRPCRouter({
|
||||||
getDashboard: publicProcedure
|
getDashboard: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
||||||
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
dashboard: data,
|
dashboard: data,
|
||||||
updatedAt: timestamp,
|
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());
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ export const stockPriceRouter = createTRPCRouter({
|
|||||||
timeRange: input.timeRange,
|
timeRange: input.timeRange,
|
||||||
timeInterval: input.timeInterval,
|
timeInterval: input.timeInterval,
|
||||||
});
|
});
|
||||||
return await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
return await innerHandler.getDataAsync();
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -19,14 +19,7 @@ export const timetableRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const innerHandler = timetableGetTimetableRequestHandler.handler({
|
const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
|
||||||
baseUrl: input.baseUrl,
|
|
||||||
stationId: input.stationId,
|
|
||||||
limit: input.limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}),
|
}),
|
||||||
searchStations: publicProcedure
|
searchStations: publicProcedure
|
||||||
@@ -37,13 +30,7 @@ export const timetableRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const innerHandler = timetableSearchStationsRequestHandler.handler({
|
const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
|
||||||
baseUrl: input.baseUrl,
|
|
||||||
query: input.query,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return data;
|
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 { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const tracearrRouter = createTRPCRouter({
|
export const tracearrRouter = createTRPCRouter({
|
||||||
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
|
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
||||||
const innerHandler = tracearrRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
dashboard: data,
|
dashboard: data,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}),
|
}),
|
||||||
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 { z } from "zod/v4";
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
} from "@homarr/request-handler/umami";
|
} from "@homarr/request-handler/umami";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const logger = createLogger({ module: "umami-router" });
|
const logger = createLogger({ module: "umami-router" });
|
||||||
@@ -39,26 +39,22 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.concat(createManyIntegrationMiddleware("query", "umami"))
|
.concat(createManyIntegrationMiddleware("query", "umami"))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
websiteId: input.websiteId,
|
||||||
websiteId: input.websiteId,
|
timeFrame: input.timeFrame,
|
||||||
timeFrame: input.timeFrame,
|
eventName: input.eventName,
|
||||||
eventName: input.eventName,
|
});
|
||||||
});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
visitorStats: data,
|
visitorStats: data,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getEventNames: publicProcedure
|
getEventNames: publicProcedure
|
||||||
@@ -67,7 +63,7 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data } = await innerHandler.getDataAsync();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -93,7 +89,7 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
timeFrame: input.timeFrame,
|
timeFrame: input.timeFrame,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
});
|
});
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data } = await innerHandler.getDataAsync();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -123,7 +119,7 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
timeFrame: input.timeFrame,
|
timeFrame: input.timeFrame,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
});
|
});
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data } = await innerHandler.getDataAsync();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -148,13 +144,13 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
const sortedNames = [...input.eventNames].sort();
|
const sortedNames = [...input.eventNames].toSorted();
|
||||||
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
|
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
|
||||||
websiteId: input.websiteId,
|
websiteId: input.websiteId,
|
||||||
timeFrame: input.timeFrame,
|
timeFrame: input.timeFrame,
|
||||||
eventNames: sortedNames,
|
eventNames: sortedNames,
|
||||||
});
|
});
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data } = await innerHandler.getDataAsync();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -176,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||||
websiteId: input.websiteId,
|
websiteId: input.websiteId,
|
||||||
});
|
});
|
||||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data } = await innerHandler.getDataAsync();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -185,21 +181,4 @@ export const umamiRouter = createTRPCRouter({
|
|||||||
return 0;
|
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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { UpsSummary } from "@homarr/integrations/types";
|
|
||||||
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
|
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const upsRouter = createTRPCRouter({
|
export const upsRouter = createTRPCRouter({
|
||||||
getSummaries: publicProcedure
|
getSummaries: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
|
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
summaries: data,
|
summaries: data,
|
||||||
updatedAt: timestamp,
|
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());
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const uptimeKumaRouter = createTRPCRouter({
|
export const uptimeKumaRouter = createTRPCRouter({
|
||||||
getDashboard: publicProcedure
|
getDashboard: publicProcedure
|
||||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const results = await Promise.all(
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
ctx.integrations.map(async (integration) => {
|
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
integrationUrl: integration.url,
|
integrationUrl: integration.url,
|
||||||
dashboard: data,
|
dashboard: data,
|
||||||
updatedAt: timestamp,
|
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());
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type { VpnSummary } from "@homarr/integrations/types";
|
|
||||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||||
|
|
||||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const createVpnIntegrationMiddleware = () =>
|
|
||||||
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
|
|
||||||
|
|
||||||
export const vpnRouter = createTRPCRouter({
|
export const vpnRouter = createTRPCRouter({
|
||||||
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
|
getSummaries: publicProcedure
|
||||||
return await Promise.all(
|
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
|
||||||
ctx.integrations.map(async (integration) => {
|
.query(async ({ ctx }) => {
|
||||||
const innerHandler = vpnSummaryHandler.handler(integration, {});
|
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integration: {
|
integration: {
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
@@ -29,31 +20,6 @@ export const vpnRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
summary: data,
|
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 { z } from "zod/v4";
|
||||||
|
|
||||||
import type { Weather } from "@homarr/request-handler/weather";
|
|
||||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
@@ -14,16 +12,6 @@ const atLocationInput = z.object({
|
|||||||
export const weatherRouter = createTRPCRouter({
|
export const weatherRouter = createTRPCRouter({
|
||||||
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
|
||||||
const handler = weatherRequestHandler.handler(input);
|
const handler = weatherRequestHandler.handler(input);
|
||||||
return await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }).then((result) => result.data);
|
return await handler.getDataAsync().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;
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -39,7 +39,7 @@ export const searchDuckDuckGoBangsAsync = async (input: {
|
|||||||
const queryTokenPrefix = normalizeBangToken(input.query);
|
const queryTokenPrefix = normalizeBangToken(input.query);
|
||||||
if (!queryTokenPrefix) return [];
|
if (!queryTokenPrefix) return [];
|
||||||
|
|
||||||
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
|
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getDataAsync();
|
||||||
const startIndex = lowerBound(allBangs, queryTokenPrefix);
|
const startIndex = lowerBound(allBangs, queryTokenPrefix);
|
||||||
const matches: DuckDuckGoBang[] = [];
|
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,
|
isPersistableWidgetQueryKey,
|
||||||
queryCacheDefaultStaleTimeMs,
|
queryCacheDefaultStaleTimeMs,
|
||||||
queryCacheMaxValueBytes,
|
queryCacheMaxValueBytes,
|
||||||
queryCacheRetentionMs,
|
|
||||||
} from "@homarr/api/query-cache";
|
} from "@homarr/api/query-cache";
|
||||||
|
|
||||||
import { queryCacheRouter } from "../router/query-cache";
|
import { queryCacheRouter } from "../router/query-cache";
|
||||||
|
|
||||||
const redisMock = vi.hoisted(() => {
|
const redisMock = vi.hoisted(() => {
|
||||||
const values = new Map<string, { value: string; expiresAt: number }>();
|
const values = new Map<string, string>();
|
||||||
const calls: {
|
|
||||||
userId: string;
|
|
||||||
boardId: string;
|
|
||||||
key: string;
|
|
||||||
ttlMs: number;
|
|
||||||
maxValueBytes: number;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
calls,
|
setQueryCacheAsync: vi.fn(
|
||||||
createQueryCacheChannel: vi.fn(
|
async (_userId: string, _boardId: string, value: string, _ttlMs: number, maxValueBytes: number) => {
|
||||||
(options: { userId: string; boardId: string; key: string; ttlMs: number; maxValueBytes: number }) => {
|
if (Buffer.byteLength(value, "utf8") > maxValueBytes) return false;
|
||||||
calls.push(options);
|
values.set(`${_userId}:${_boardId}`, value);
|
||||||
const storageKey = `${options.userId}:${options.boardId}:${options.key}`;
|
return true;
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
removeQueryCacheAsync: vi.fn(async (_userId: string, _boardId: string) => {
|
||||||
|
values.delete(`${_userId}:${_boardId}`);
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@homarr/redis", () => ({
|
vi.mock("@homarr/redis", () => ({
|
||||||
createQueryCacheChannel: redisMock.createQueryCacheChannel,
|
setQueryCacheAsync: redisMock.setQueryCacheAsync,
|
||||||
|
removeQueryCacheAsync: redisMock.removeQueryCacheAsync,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||||
@@ -70,8 +42,8 @@ vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
redisMock.values.clear();
|
redisMock.values.clear();
|
||||||
redisMock.calls.length = 0;
|
redisMock.setQueryCacheAsync.mockClear();
|
||||||
redisMock.createQueryCacheChannel.mockClear();
|
redisMock.removeQueryCacheAsync.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSession = (userId = createId()): Session => ({
|
const createSession = (userId = createId()): Session => ({
|
||||||
@@ -104,7 +76,7 @@ describe("isPersistableWidgetQueryKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("queryCacheRouter", () => {
|
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 db = createDb();
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
await insertUserAsync(db, session.user.id);
|
await insertUserAsync(db, session.user.id);
|
||||||
@@ -112,14 +84,19 @@ describe("queryCacheRouter", () => {
|
|||||||
|
|
||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
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,
|
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 caller.removeItem({ boardId: "board-1" });
|
||||||
|
expect(redisMock.removeQueryCacheAsync).toHaveBeenCalledWith(session.user.id, "board-1");
|
||||||
await expect(caller.getItem({ boardId: "board-1", key: "query-key" })).resolves.toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not touch Redis when board access is denied", async () => {
|
test("does not touch Redis when board access is denied", async () => {
|
||||||
@@ -127,32 +104,32 @@ describe("queryCacheRouter", () => {
|
|||||||
await insertUserAsync(db, "owner");
|
await insertUserAsync(db, "owner");
|
||||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: false });
|
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") });
|
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");
|
await expect(caller.setItem({ boardId: "board-1", value: "cached-value" })).rejects.toThrow("Board not found");
|
||||||
expect(redisMock.createQueryCacheChannel).not.toHaveBeenCalled();
|
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();
|
const db = createDb();
|
||||||
await insertUserAsync(db, "owner");
|
await insertUserAsync(db, "owner");
|
||||||
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
|
await db.insert(boards).values({ id: "board-1", name: "board", creatorId: "owner", isPublic: true });
|
||||||
|
|
||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session: null });
|
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({
|
expect(redisMock.setQueryCacheAsync).toHaveBeenCalledWith(
|
||||||
userId: "anonymous",
|
"anonymous",
|
||||||
boardId: "board-1",
|
"board-1",
|
||||||
key: "query-key",
|
"cached-value",
|
||||||
ttlMs: queryCacheRetentionMs,
|
expect.any(Number),
|
||||||
maxValueBytes: queryCacheMaxValueBytes,
|
queryCacheMaxValueBytes,
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scopes signed-in cache entries by user and rejects oversized values", async () => {
|
test("rejects oversized values", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const session = createSession("user-1");
|
const session = createSession("user-1");
|
||||||
await insertUserAsync(db, session.user.id);
|
await insertUserAsync(db, session.user.id);
|
||||||
@@ -161,34 +138,9 @@ describe("queryCacheRouter", () => {
|
|||||||
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
const caller = queryCacheRouter.createCaller({ db, deviceType: undefined, session });
|
||||||
const value = "x".repeat(queryCacheMaxValueBytes + 1);
|
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,
|
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) :
|
|||||||
*/
|
*/
|
||||||
export const publicProcedure = baseProcedure;
|
export const publicProcedure = baseProcedure;
|
||||||
|
|
||||||
|
export const internalProcedure = t.procedure;
|
||||||
|
|
||||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||||
if (!ctx.session?.user) {
|
if (!ctx.session?.user) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export const authorizeWithBasicCredentialsAsync = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
logger.warn("User is disabled", { userName: user.name });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("User is trying to log in. Checking password...", { userName: user.name });
|
logger.info("User is trying to log in. Checking password...", { userName: user.name });
|
||||||
const isValidPassword = await comparePasswordsAsync(credentials.password, user.password);
|
const isValidPassword = await comparePasswordsAsync(credentials.password, user.password);
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export const authorizeWithLdapCredentialsAsync = async (
|
|||||||
email: true,
|
email: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
|
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
|
||||||
});
|
});
|
||||||
@@ -130,6 +131,11 @@ export const authorizeWithLdapCredentialsAsync = async (
|
|||||||
logger.info("User created successfully", { userName: credentials.name });
|
logger.info("User created successfully", { userName: credentials.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
logger.warn("User is disabled", { userName: credentials.name });
|
||||||
|
throw new CredentialsSignin("User is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: credentials.name,
|
name: credentials.name,
|
||||||
|
|||||||
@@ -20,16 +20,7 @@ export const BoardProvider = ({
|
|||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
initialBoard: RouterOutputs["board"]["getBoardByName"];
|
||||||
}>) => {
|
}>) => {
|
||||||
const { data } = clientApi.board.getBoardByName.useQuery(
|
const { data } = clientApi.board.getBoardByName.useQuery({ name: initialBoard.name }, { initialData: initialBoard });
|
||||||
{ name: initialBoard.name },
|
|
||||||
{
|
|
||||||
initialData: initialBoard,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
setActiveQueryCacheBoardId(data.id);
|
|
||||||
|
|
||||||
// Update the board name so it can be used within updateBoard method
|
// Update the board name so it can be used within updateBoard method
|
||||||
updateBoardName(initialBoard.name);
|
updateBoardName(initialBoard.name);
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ export function formatDuration(milliseconds: number) {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const toValidDate = (value: unknown): Date | null => {
|
||||||
|
if (value == null || typeof value === "boolean") return null;
|
||||||
|
const date = value instanceof Date ? value : new Date(value as string | number);
|
||||||
|
return Number.isFinite(date.getTime()) ? date : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const isDateWithin = (date: Date, relativeDate: string): boolean => {
|
export const isDateWithin = (date: Date, relativeDate: string): boolean => {
|
||||||
if (relativeDate.length < 2) {
|
if (relativeDate.length < 2) {
|
||||||
throw new Error("Relative date must be at least 2 characters long");
|
throw new Error("Relative date must be at least 2 characters long");
|
||||||
|
|||||||
@@ -1,32 +1,6 @@
|
|||||||
import { analyticsJob } from "./jobs/analytics";
|
import { analyticsJob } from "./jobs/analytics";
|
||||||
import { dockerContainersJob } from "./jobs/docker";
|
|
||||||
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
import { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||||
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
|
|
||||||
import { downloadsJob } from "./jobs/integrations/downloads";
|
|
||||||
import {
|
|
||||||
firewallCpuJob,
|
|
||||||
firewallInterfacesJob,
|
|
||||||
firewallMemoryJob,
|
|
||||||
firewallVersionJob,
|
|
||||||
} from "./jobs/integrations/firewall";
|
|
||||||
import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
|
|
||||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
|
||||||
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
|
||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
|
||||||
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
|
||||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
|
||||||
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
|
||||||
import { networkControllerJob } from "./jobs/integrations/network-controller";
|
|
||||||
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
|
||||||
import { timetableJob } from "./jobs/integrations/timetable";
|
|
||||||
import { tracearrJob } from "./jobs/integrations/tracearr";
|
|
||||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
|
||||||
import { vpnJob } from "./jobs/integrations/vpn";
|
|
||||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
|
||||||
import { updateCheckerJob } from "./jobs/update-checker";
|
|
||||||
import { weatherJob } from "./jobs/weather";
|
|
||||||
import { createCronJobGroup } from "./lib";
|
import { createCronJobGroup } from "./lib";
|
||||||
|
|
||||||
const getJobGroup = () => {
|
const getJobGroup = () => {
|
||||||
@@ -34,35 +8,9 @@ const getJobGroup = () => {
|
|||||||
analytics: analyticsJob,
|
analytics: analyticsJob,
|
||||||
iconsUpdater: iconsUpdaterJob,
|
iconsUpdater: iconsUpdaterJob,
|
||||||
ping: pingJob,
|
ping: pingJob,
|
||||||
smartHomeEntityState: smartHomeEntityStateJob,
|
|
||||||
mediaServer: mediaServerJob,
|
|
||||||
mediaOrganizer: mediaOrganizerJob,
|
|
||||||
downloads: downloadsJob,
|
|
||||||
dnsHole: dnsHoleJob,
|
|
||||||
mediaRequestStats: mediaRequestStatsJob,
|
|
||||||
mediaRequestList: mediaRequestListJob,
|
|
||||||
rssFeeds: rssFeedsJob,
|
|
||||||
indexerManager: indexerManagerJob,
|
|
||||||
healthMonitoring: healthMonitoringJob,
|
|
||||||
updateChecker: updateCheckerJob,
|
|
||||||
mediaTranscoding: mediaTranscodingJob,
|
|
||||||
minecraftServerStatus: minecraftServerStatusJob,
|
|
||||||
dockerContainers: dockerContainersJob,
|
|
||||||
networkController: networkControllerJob,
|
|
||||||
firewallCpu: firewallCpuJob,
|
|
||||||
firewallMemory: firewallMemoryJob,
|
|
||||||
firewallVersion: firewallVersionJob,
|
|
||||||
firewallInterfaces: firewallInterfacesJob,
|
|
||||||
refreshNotifications: refreshNotificationsJob,
|
|
||||||
weather: weatherJob,
|
|
||||||
timetable: timetableJob,
|
|
||||||
tracearr: tracearrJob,
|
|
||||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
|
||||||
vpn: vpnJob,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add global type augmentation for homarr
|
|
||||||
declare global {
|
declare global {
|
||||||
var cronJobs: ReturnType<typeof getJobGroup> | undefined;
|
var cronJobs: ReturnType<typeof getJobGroup> | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
|
||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { db, eq } from "@homarr/db";
|
|
||||||
import { items } from "@homarr/db/schema";
|
|
||||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../../widgets";
|
|
||||||
import { createCronJob } from "../lib";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "dockerJobs" });
|
|
||||||
|
|
||||||
export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUTE).withCallback(async () => {
|
|
||||||
const dockerItems = await db.query.items.findMany({
|
|
||||||
where: eq(items.kind, "dockerContainers"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
dockerItems.map(async (item) => {
|
|
||||||
try {
|
|
||||||
const options = SuperJSON.parse<WidgetComponentProps<"dockerContainers">["options"]>(item.options);
|
|
||||||
const innerHandler = dockerContainersRequestHandler.handler(options);
|
|
||||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(new ErrorWithMetadata("Failed to update Docker container status", { item }, { cause: error }));
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const dnsHoleJob = createCronJob("dnsHole", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, {
|
|
||||||
widgetKinds: ["dnsHoleSummary", "dnsHoleControls"],
|
|
||||||
getInput: {
|
|
||||||
dnsHoleSummary: () => ({}),
|
|
||||||
dnsHoleControls: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
|
|
||||||
widgetKinds: ["downloads"],
|
|
||||||
getInput: {
|
|
||||||
downloads: (options) => ({
|
|
||||||
limit: options.limitPerIntegration,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { EVERY_5_SECONDS, EVERY_30_SECONDS, EVERY_HOUR, EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import {
|
|
||||||
firewallCpuRequestHandler,
|
|
||||||
firewallInterfacesRequestHandler,
|
|
||||||
firewallMemoryRequestHandler,
|
|
||||||
firewallVersionRequestHandler,
|
|
||||||
} from "@homarr/request-handler/firewall";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const firewallCpuJob = createCronJob("firewallCpu", EVERY_5_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(firewallCpuRequestHandler.handler, {
|
|
||||||
widgetKinds: ["firewall"],
|
|
||||||
getInput: {
|
|
||||||
firewall: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const firewallMemoryJob = createCronJob("firewallMemory", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(firewallMemoryRequestHandler.handler, {
|
|
||||||
widgetKinds: ["firewall"],
|
|
||||||
getInput: {
|
|
||||||
firewall: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const firewallInterfacesJob = createCronJob("firewallInterfaces", EVERY_30_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(firewallInterfacesRequestHandler.handler, {
|
|
||||||
widgetKinds: ["firewall"],
|
|
||||||
getInput: {
|
|
||||||
firewall: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const firewallVersionJob = createCronJob("firewallVersion", EVERY_HOUR).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(firewallVersionRequestHandler.handler, {
|
|
||||||
widgetKinds: ["firewall"],
|
|
||||||
getInput: {
|
|
||||||
firewall: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
|
||||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(
|
|
||||||
(integration, itemOptions: Record<string, never>) => {
|
|
||||||
const { kind } = integration;
|
|
||||||
|
|
||||||
if (kind === "beszel") {
|
|
||||||
return null as never;
|
|
||||||
}
|
|
||||||
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
|
|
||||||
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
|
||||||
}
|
|
||||||
if (kind === "coolify") {
|
|
||||||
return coolifyRequestHandler.handler({ ...integration, kind }, itemOptions);
|
|
||||||
}
|
|
||||||
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
widgetKinds: ["healthMonitoring", "systemResources", "coolify"],
|
|
||||||
getInput: {
|
|
||||||
healthMonitoring: () => ({}),
|
|
||||||
systemResources: () => ({}),
|
|
||||||
coolify: () => ({}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, {
|
|
||||||
widgetKinds: ["smartHome-entityState"],
|
|
||||||
getInput: {
|
|
||||||
"smartHome-entityState": (options) => ({
|
|
||||||
entityId: options.entityId,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, {
|
|
||||||
widgetKinds: ["indexerManager"],
|
|
||||||
getInput: {
|
|
||||||
indexerManager: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, {
|
|
||||||
widgetKinds: ["calendar"],
|
|
||||||
getInput: {
|
|
||||||
// Request handler will run for all specified months
|
|
||||||
calendar: (options) => {
|
|
||||||
const inputs = [];
|
|
||||||
|
|
||||||
const startOffset = -Number(options.filterPastMonths);
|
|
||||||
const endOffset = Number(options.filterFutureMonths);
|
|
||||||
|
|
||||||
for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) {
|
|
||||||
const year = dayjs().subtract(offsetMonths, "months").year();
|
|
||||||
const month = dayjs().subtract(offsetMonths, "months").month();
|
|
||||||
|
|
||||||
inputs.push({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
releaseType: options.releaseType,
|
|
||||||
showUnmonitored: options.showUnmonitored,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputs;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
|
||||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, {
|
|
||||||
widgetKinds: ["mediaRequests-requestStats"],
|
|
||||||
getInput: {
|
|
||||||
"mediaRequests-requestStats": () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
|
|
||||||
widgetKinds: ["mediaRequests-requestList"],
|
|
||||||
getInput: {
|
|
||||||
"mediaRequests-requestList": () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
|
|
||||||
widgetKinds: ["mediaServer"],
|
|
||||||
getInput: {
|
|
||||||
mediaServer: ({ showOnlyPlaying }) => ({ showOnlyPlaying }),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const mediaTranscodingJob = createCronJob("mediaTranscoding", EVERY_5_MINUTES).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(mediaTranscodingRequestHandler.handler, {
|
|
||||||
widgetKinds: ["mediaTranscoding"],
|
|
||||||
getInput: {
|
|
||||||
mediaTranscoding: () => ({ pageOffset: 0, pageSize: 10 }),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const networkControllerJob = createCronJob("networkController", EVERY_MINUTE).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(networkControllerRequestHandler.handler, {
|
|
||||||
widgetKinds: ["networkControllerSummary"],
|
|
||||||
getInput: {
|
|
||||||
networkControllerSummary: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const refreshNotificationsJob = createCronJob("refreshNotifications", EVERY_5_MINUTES).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(notificationsRequestHandler.handler, {
|
|
||||||
widgetKinds: ["notifications"],
|
|
||||||
getInput: {
|
|
||||||
notifications: (options) => options,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
|
||||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { db, eq } from "@homarr/db";
|
|
||||||
import { items } from "@homarr/db/schema";
|
|
||||||
import { timetableGetTimetableRequestHandler } from "@homarr/request-handler/timetable";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../../../widgets";
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "timetableJobs" });
|
|
||||||
|
|
||||||
export const timetableJob = createCronJob("timetable", EVERY_5_MINUTES).withCallback(async () => {
|
|
||||||
const timetableItems = await db.query.items.findMany({
|
|
||||||
where: eq(items.kind, "timetable"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedItems = timetableItems.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
options: SuperJSON.parse<WidgetComponentProps<"timetable">["options"]>(item.options),
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (const item of parsedItems) {
|
|
||||||
if (!item.options.station) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const innerHandler = timetableGetTimetableRequestHandler.handler({
|
|
||||||
baseUrl: item.options.baseUrl,
|
|
||||||
stationId: item.options.station.value,
|
|
||||||
limit: 10,
|
|
||||||
});
|
|
||||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(new ErrorWithMetadata("Failed to update timetable", { id: item.id }, { cause: error }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const tracearrJob = createCronJob("tracearr", EVERY_5_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(tracearrRequestHandler.handler, {
|
|
||||||
widgetKinds: ["tracearr"],
|
|
||||||
getInput: {
|
|
||||||
tracearr: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
import { umamiActiveVisitorsRequestHandler } from "@homarr/request-handler/umami";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const umamiActiveVisitorsJob = createCronJob("umamiActiveVisitors", EVERY_30_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(umamiActiveVisitorsRequestHandler.handler, {
|
|
||||||
widgetKinds: ["umami"],
|
|
||||||
getInput: {
|
|
||||||
umami: (options) => (options.websiteId ? { websiteId: options.websiteId } : []),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
|
||||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
|
||||||
|
|
||||||
import { createCronJob } from "../../lib";
|
|
||||||
|
|
||||||
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
|
|
||||||
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
|
|
||||||
widgetKinds: ["vpn"],
|
|
||||||
getInput: {
|
|
||||||
vpn: () => ({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { db, eq } from "@homarr/db";
|
|
||||||
import { items } from "@homarr/db/schema";
|
|
||||||
import { minecraftServerStatusRequestHandler } from "@homarr/request-handler/minecraft-server-status";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../../widgets/src";
|
|
||||||
import { createCronJob } from "../lib";
|
|
||||||
|
|
||||||
export const minecraftServerStatusJob = createCronJob("minecraftServerStatus", EVERY_5_MINUTES).withCallback(
|
|
||||||
async () => {
|
|
||||||
const dbItems = await db.query.items.findMany({
|
|
||||||
where: eq(items.kind, "minecraftServerStatus"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
dbItems.map(async (item) => {
|
|
||||||
const options = SuperJSON.parse<WidgetComponentProps<"minecraftServerStatus">["options"]>(item.options);
|
|
||||||
const innerHandler = minecraftServerStatusRequestHandler.handler(options);
|
|
||||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
|
||||||
import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { db, eq } from "@homarr/db";
|
|
||||||
import { items } from "@homarr/db/schema";
|
|
||||||
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
|
|
||||||
|
|
||||||
// This import is done that way to avoid circular dependencies.
|
|
||||||
import type { WidgetComponentProps } from "../../../widgets";
|
|
||||||
import { createCronJob } from "../lib";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "rssFeedsJobs" });
|
|
||||||
|
|
||||||
export const rssFeedsJob = createCronJob("rssFeeds", EVERY_10_MINUTES).withCallback(async () => {
|
|
||||||
const rssItems = await db.query.items.findMany({
|
|
||||||
where: eq(items.kind, "rssFeed"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const itemOptions = rssItems.map((item) => SuperJSON.parse<WidgetComponentProps<"rssFeed">["options"]>(item.options));
|
|
||||||
|
|
||||||
for (const option of itemOptions) {
|
|
||||||
const maxAmountPosts = typeof option.maximumAmountPosts === "number" ? option.maximumAmountPosts : 100;
|
|
||||||
for (const url of option.feedUrls) {
|
|
||||||
try {
|
|
||||||
const innerHandler = rssFeedsRequestHandler.handler({
|
|
||||||
url,
|
|
||||||
count: maxAmountPosts,
|
|
||||||
});
|
|
||||||
await innerHandler.getCachedOrUpdatedDataAsync({
|
|
||||||
forceUpdate: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(new ErrorWithMetadata("Failed to update RSS feed", { url }, { cause: error }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { EVERY_DAY } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { updateCheckerRequestHandler } from "@homarr/request-handler/update-checker";
|
|
||||||
|
|
||||||
import { createCronJob } from "../lib";
|
|
||||||
|
|
||||||
export const updateCheckerJob = createCronJob("updateChecker", EVERY_DAY, {
|
|
||||||
runOnStart: true,
|
|
||||||
}).withCallback(async () => {
|
|
||||||
const handler = updateCheckerRequestHandler.handler({});
|
|
||||||
await handler.getCachedOrUpdatedDataAsync({});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import SuperJSON from "superjson";
|
|
||||||
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
|
||||||
import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
||||||
import { db, eq } from "@homarr/db";
|
|
||||||
import { items } from "@homarr/db/schema";
|
|
||||||
import { weatherRequestHandler } from "@homarr/request-handler/weather";
|
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../../widgets";
|
|
||||||
import { createCronJob } from "../lib";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "weatherJobs" });
|
|
||||||
|
|
||||||
export const weatherJob = createCronJob("weather", EVERY_10_MINUTES).withCallback(async () => {
|
|
||||||
const weatherItems = await db.query.items.findMany({
|
|
||||||
where: eq(items.kind, "weather"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedItems = weatherItems.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
options: SuperJSON.parse<WidgetComponentProps<"weather">["options"]>(item.options),
|
|
||||||
}));
|
|
||||||
|
|
||||||
for (const item of parsedItems) {
|
|
||||||
try {
|
|
||||||
const innerHandler = weatherRequestHandler.handler({
|
|
||||||
longitude: item.options.location.longitude,
|
|
||||||
latitude: item.options.location.latitude,
|
|
||||||
});
|
|
||||||
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(new ErrorWithMetadata("Failed to update weather", { id: item.id }, { cause: error }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user` ADD `disabled` boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `widget_secret` (
|
||||||
|
`item_id` varchar(64) NOT NULL,
|
||||||
|
`kind` varchar(64) NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`updated_at` timestamp NOT NULL,
|
||||||
|
CONSTRAINT `widget_secret_item_id_kind_pk` PRIMARY KEY(`item_id`,`kind`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `widget_secret` ADD CONSTRAINT `widget_secret_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,13 @@
|
|||||||
"when": 1780764500000,
|
"when": 1780764500000,
|
||||||
"tag": "0041_merge_custom_widget_url",
|
"tag": "0041_merge_custom_widget_url",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 42,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1783176100870,
|
||||||
|
"tag": "0042_add_widget_secret",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "widget_secret" (
|
||||||
|
"item_id" varchar(64) NOT NULL,
|
||||||
|
"kind" varchar(64) NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
CONSTRAINT "widget_secret_item_id_kind_pk" PRIMARY KEY("item_id","kind")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "widget_secret" ADD CONSTRAINT "widget_secret_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "disabled" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user