Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb238ed4f | ||
|
|
449adc5b12 | ||
|
|
240397e7ac | ||
|
|
3efb719eee | ||
|
|
382cffc015 | ||
|
|
0073dfe044 | ||
|
|
a22fb60647 | ||
|
|
89e4cb843d | ||
|
|
65a96b6b14 | ||
|
|
357355283c | ||
|
|
a2a408410a | ||
|
|
0d9eeb5752 | ||
|
|
bf0878c68b | ||
|
|
eb6d337dfb | ||
|
|
978b3c7f33 | ||
|
|
07547320f1 | ||
|
|
b6fc27e44d | ||
|
|
9eafd4f7a7 | ||
|
|
71ffa5d1be |
No files matched your search
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
name: homarr-local-harness
|
|
||||||
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Homarr Local Harness
|
|
||||||
|
|
||||||
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
|
|
||||||
|
|
||||||
## Set up the harness
|
|
||||||
|
|
||||||
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness setup feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
|
|
||||||
|
|
||||||
- `DEMO_MODE=true`
|
|
||||||
- `DEMO_READ_ONLY=false`
|
|
||||||
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
|
|
||||||
|
|
||||||
Pass repeatable environment overrides when the feature needs them:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness setup feature-board \
|
|
||||||
--env WORKSHOP_WEB_URL=https://example.test \
|
|
||||||
--env FEATURE_FLAG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
|
|
||||||
|
|
||||||
Always use the URL printed by the command. If the output is no longer available, query it directly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness port feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
|
|
||||||
|
|
||||||
## Browser validation
|
|
||||||
|
|
||||||
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
agent-browser skills get core
|
|
||||||
```
|
|
||||||
|
|
||||||
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
Stop the named container when finished:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm cli harness stop feature-board
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Homarr Local Harness"
|
|
||||||
short_description: "Build and browse a writable local Homarr branch harness"
|
|
||||||
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
|
|
||||||
@@ -30,6 +30,26 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"reportUnusedDisableDirectives": "off"
|
"reportUnusedDisableDirectives": "off"
|
||||||
},
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
|
||||||
|
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||||
|
"rules": {
|
||||||
|
"eslint/no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "@homarr/widgets",
|
||||||
|
"allowTypeImports": true,
|
||||||
|
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/*.config.js",
|
"**/*.config.js",
|
||||||
"**/*.config.mjs",
|
"**/*.config.mjs",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"@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:",
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { dehydrate, QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { PersistedClient } from "@tanstack/react-query-persist-client";
|
||||||
|
import { persistQueryClientRestore } from "@tanstack/react-query-persist-client";
|
||||||
|
import { parse, stringify } from "superjson";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSessionQueryPersister,
|
||||||
|
getQueryPersistenceStorageKey,
|
||||||
|
queryPersistenceBuster,
|
||||||
|
queryPersistenceMaxDataBytes,
|
||||||
|
shouldPersistDashboardQuery,
|
||||||
|
} from "./query-persistence";
|
||||||
|
|
||||||
|
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
queryClient.setQueryData(queryKey, data, { updatedAt });
|
||||||
|
const query = queryClient.getQueryCache().find({ queryKey });
|
||||||
|
if (!query) throw new Error("Expected query to be present");
|
||||||
|
return { query, queryClient };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMemoryStorage = (): Storage => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return values.size;
|
||||||
|
},
|
||||||
|
clear: () => values.clear(),
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||||
|
removeItem: (key) => values.delete(key),
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("widget query persistence", () => {
|
||||||
|
test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => {
|
||||||
|
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
||||||
|
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
|
||||||
|
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
|
||||||
|
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
|
||||||
|
const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], {
|
||||||
|
value: "x".repeat(queryPersistenceMaxDataBytes + 1),
|
||||||
|
});
|
||||||
|
const pendingClient = new QueryClient();
|
||||||
|
const pending = pendingClient.getQueryCache().build(pendingClient, {
|
||||||
|
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||||
|
queryFn: () => Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
|
||||||
|
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
|
||||||
|
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(oversized.query)).toBe(false);
|
||||||
|
expect(shouldPersistDashboardQuery(pending)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => {
|
||||||
|
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
|
||||||
|
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
|
||||||
|
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
|
||||||
|
|
||||||
|
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||||
|
window.sessionStorage.setItem(userAKey, "a");
|
||||||
|
createSessionQueryPersister("user-a").removeClient();
|
||||||
|
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evicts every other scope so signing out cannot leave data behind", () => {
|
||||||
|
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||||
|
const anonymousKey = getQueryPersistenceStorageKey(null);
|
||||||
|
window.sessionStorage.setItem(userAKey, "a");
|
||||||
|
window.sessionStorage.setItem(anonymousKey, "anonymous");
|
||||||
|
window.sessionStorage.setItem("unrelated", "kept");
|
||||||
|
|
||||||
|
createSessionQueryPersister("user-b");
|
||||||
|
|
||||||
|
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||||
|
expect(window.sessionStorage.getItem(anonymousKey)).toBeNull();
|
||||||
|
expect(window.sessionStorage.getItem("unrelated")).toBe("kept");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never restores another session scope", async () => {
|
||||||
|
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||||
|
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
|
||||||
|
const userAPersister = createSessionQueryPersister("user-a");
|
||||||
|
userAPersister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(queryClient),
|
||||||
|
});
|
||||||
|
userAPersister.flush();
|
||||||
|
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops persisting once the provider tears the cache down", async () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const persister = createSessionQueryPersister("user-a", storage);
|
||||||
|
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||||
|
temp: 21,
|
||||||
|
});
|
||||||
|
|
||||||
|
persister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(queryClient),
|
||||||
|
});
|
||||||
|
persister.flush();
|
||||||
|
persister.stop();
|
||||||
|
|
||||||
|
persister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: { mutations: [], queries: [] },
|
||||||
|
});
|
||||||
|
persister.flush();
|
||||||
|
|
||||||
|
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes the cache instead of storing an empty one", () => {
|
||||||
|
const scope = "user-a";
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
const persister = createSessionQueryPersister(scope);
|
||||||
|
window.sessionStorage.setItem(key, "stale");
|
||||||
|
|
||||||
|
persister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: { mutations: [], queries: [] },
|
||||||
|
});
|
||||||
|
persister.flush();
|
||||||
|
|
||||||
|
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = "user-a";
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
const persister = createSessionQueryPersister(scope);
|
||||||
|
const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
|
||||||
|
startsAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" });
|
||||||
|
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||||
|
const persistedClient: PersistedClient = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||||
|
};
|
||||||
|
|
||||||
|
persister.persistClient(persistedClient);
|
||||||
|
persister.flush();
|
||||||
|
const restored = await persister.restoreClient();
|
||||||
|
const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined;
|
||||||
|
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
||||||
|
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||||
|
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||||
|
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||||
|
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||||
|
expect(persister.restoreClient()).toBeUndefined();
|
||||||
|
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
|
||||||
|
const scope = "user-a";
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
const { queryClient } = createSuccessfulQuery(
|
||||||
|
[["widget", "calendar", "findAllEvents"], { type: "query" }],
|
||||||
|
[{ title: "safe" }],
|
||||||
|
);
|
||||||
|
const validQuery = dehydrate(queryClient).queries[0];
|
||||||
|
if (!validQuery) throw new Error("Expected a dehydrated query");
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
key,
|
||||||
|
stringify({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: {
|
||||||
|
mutations: [{ state: { status: "pending" } }],
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
...validQuery,
|
||||||
|
state: {
|
||||||
|
...validQuery.state,
|
||||||
|
error: new Error("private refresh error"),
|
||||||
|
fetchStatus: "fetching",
|
||||||
|
status: "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...validQuery,
|
||||||
|
queryHash: "unrelated",
|
||||||
|
queryKey: [["board", "getBoardByName"], { type: "query" }],
|
||||||
|
},
|
||||||
|
{ queryHash: "malformed", queryKey: [["widget", "weather"]], state: null },
|
||||||
|
{
|
||||||
|
...validQuery,
|
||||||
|
queryHash: "missing-timestamp",
|
||||||
|
state: { ...validQuery.state, dataUpdatedAt: undefined },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const restored = await createSessionQueryPersister(scope).restoreClient();
|
||||||
|
expect(restored?.clientState.mutations).toEqual([]);
|
||||||
|
expect(restored?.clientState.queries).toHaveLength(1);
|
||||||
|
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||||
|
expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle");
|
||||||
|
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["expired", 1, queryPersistenceBuster],
|
||||||
|
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
||||||
|
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
||||||
|
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
||||||
|
const scope = "user-a";
|
||||||
|
const persister = createSessionQueryPersister(scope);
|
||||||
|
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
|
||||||
|
temp: 21,
|
||||||
|
});
|
||||||
|
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
|
||||||
|
persister.flush();
|
||||||
|
|
||||||
|
const restoredClient = new QueryClient();
|
||||||
|
await persistQueryClientRestore({
|
||||||
|
queryClient: restoredClient,
|
||||||
|
persister,
|
||||||
|
maxAge: 5 * 60 * 1_000,
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
||||||
|
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quota retry writes the cache after evicting the oldest query", async () => {
|
||||||
|
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
|
||||||
|
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2);
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const setItem = storage.setItem.bind(storage);
|
||||||
|
const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => {
|
||||||
|
const client = parse<PersistedClient>(value);
|
||||||
|
if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError");
|
||||||
|
setItem(key, value);
|
||||||
|
});
|
||||||
|
const persistedClient: PersistedClient = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: {
|
||||||
|
mutations: [],
|
||||||
|
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const persister = createSessionQueryPersister("user-a", storage);
|
||||||
|
|
||||||
|
persister.persistClient(persistedClient);
|
||||||
|
persister.flush();
|
||||||
|
|
||||||
|
expect(setItemSpy).toHaveBeenCalledTimes(2);
|
||||||
|
const restored = await persister.restoreClient();
|
||||||
|
expect(restored?.clientState.queries).toHaveLength(1);
|
||||||
|
expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const setItem = vi.spyOn(storage, "setItem");
|
||||||
|
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||||
|
const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1);
|
||||||
|
const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2);
|
||||||
|
const persister = createSessionQueryPersister("user-a", storage);
|
||||||
|
|
||||||
|
persister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(first.queryClient),
|
||||||
|
});
|
||||||
|
persister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(latest.queryClient),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
persister.flush();
|
||||||
|
expect(setItem).toHaveBeenCalledOnce();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(setItem).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
const restored = await persister.restoreClient();
|
||||||
|
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats blocked storage operations as an empty cache", async () => {
|
||||||
|
const blockedStorage = {
|
||||||
|
getItem: () => {
|
||||||
|
throw new DOMException("blocked", "SecurityError");
|
||||||
|
},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new DOMException("blocked", "SecurityError");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new DOMException("blocked", "SecurityError");
|
||||||
|
},
|
||||||
|
} as unknown as Storage;
|
||||||
|
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
||||||
|
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
||||||
|
|
||||||
|
expect(persister.restoreClient()).toBeUndefined();
|
||||||
|
expect(() => {
|
||||||
|
persister.persistClient({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
clientState: dehydrate(queryClient),
|
||||||
|
});
|
||||||
|
persister.flush();
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => persister.removeClient()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats an unavailable session storage getter as an empty cache", async () => {
|
||||||
|
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
|
||||||
|
throw new DOMException("blocked", "SecurityError");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||||
|
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||||
|
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||||
|
import { parse, stringify } from "superjson";
|
||||||
|
|
||||||
|
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
|
||||||
|
|
||||||
|
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||||
|
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||||
|
export const queryPersistenceBuster = "v2-session-superjson";
|
||||||
|
|
||||||
|
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||||
|
|
||||||
|
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||||
|
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
||||||
|
|
||||||
|
interface PersistableQuery {
|
||||||
|
queryKey: QueryKey;
|
||||||
|
state: {
|
||||||
|
data: unknown;
|
||||||
|
dataUpdatedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
const budgetVerdictCache = new WeakMap<object, boolean>();
|
||||||
|
|
||||||
|
const measureFitsBudget = (data: unknown) => {
|
||||||
|
try {
|
||||||
|
return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitsStorageBudget = (data: unknown) => {
|
||||||
|
if (typeof data !== "object" || data === null) return measureFitsBudget(data);
|
||||||
|
|
||||||
|
const cached = budgetVerdictCache.get(data);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const verdict = measureFitsBudget(data);
|
||||||
|
budgetVerdictCache.set(data, verdict);
|
||||||
|
return verdict;
|
||||||
|
};
|
||||||
|
|
||||||
|
const holdsDashboardData = (query: PersistableQuery) =>
|
||||||
|
query.state.data !== undefined &&
|
||||||
|
Number.isFinite(query.state.dataUpdatedAt) &&
|
||||||
|
query.state.dataUpdatedAt > 0 &&
|
||||||
|
isWidgetDataQueryKey(query.queryKey);
|
||||||
|
|
||||||
|
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
|
||||||
|
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
|
||||||
|
|
||||||
|
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
|
||||||
|
|
||||||
|
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||||
|
...query,
|
||||||
|
state: {
|
||||||
|
...query.state,
|
||||||
|
error: null,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
fetchFailureCount: 0,
|
||||||
|
fetchFailureReason: null,
|
||||||
|
fetchStatus: "idle",
|
||||||
|
isInvalidated: true,
|
||||||
|
status: "success",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||||
|
...client,
|
||||||
|
clientState: {
|
||||||
|
mutations: [],
|
||||||
|
queries: client.clientState.queries.map(sanitizePersistedQuery),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
|
||||||
|
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
|
||||||
|
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
|
||||||
|
|
||||||
|
return holdsDashboardData(value as unknown as PersistableQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => {
|
||||||
|
if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined;
|
||||||
|
if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: value.timestamp as number,
|
||||||
|
buster: value.buster,
|
||||||
|
clientState: {
|
||||||
|
mutations: [],
|
||||||
|
queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionStorage = (): Storage | undefined => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const foreignKeys: string[] = [];
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const storedKey = storage.key(index);
|
||||||
|
if (storedKey === null || storedKey === activeKey) continue;
|
||||||
|
if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const foreignKey of foreignKeys) storage.removeItem(foreignKey);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSessionQueryPersister = (
|
||||||
|
scope: string | null,
|
||||||
|
storage: Storage | undefined = getSessionStorage(),
|
||||||
|
): SessionQueryPersister => {
|
||||||
|
const key = getQueryPersistenceStorageKey(scope);
|
||||||
|
removeForeignScopes(storage, key);
|
||||||
|
let pendingClient: PersistedClient | undefined;
|
||||||
|
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const persistNow = (client: PersistedClient) => {
|
||||||
|
if (!storage) return;
|
||||||
|
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
if (candidate.clientState.queries.length === 0) {
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (candidate) {
|
||||||
|
try {
|
||||||
|
storage.setItem(key, stringify(candidate));
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
errorCount += 1;
|
||||||
|
candidate = removeOldestQuery({
|
||||||
|
persistedClient: candidate,
|
||||||
|
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
|
||||||
|
errorCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPersistTimer = () => {
|
||||||
|
if (persistTimer === undefined) return;
|
||||||
|
clearTimeout(persistTimer);
|
||||||
|
persistTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
clearPersistTimer();
|
||||||
|
const client = pendingClient;
|
||||||
|
pendingClient = undefined;
|
||||||
|
if (client) persistNow(client);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
persistClient(client) {
|
||||||
|
if (!storage || stopped) return;
|
||||||
|
pendingClient = client;
|
||||||
|
if (persistTimer !== undefined) return;
|
||||||
|
persistTimer = setTimeout(flush, 0);
|
||||||
|
},
|
||||||
|
restoreClient() {
|
||||||
|
try {
|
||||||
|
const storedValue = storage?.getItem(key);
|
||||||
|
if (!storedValue) return undefined;
|
||||||
|
|
||||||
|
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
||||||
|
if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) {
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
} catch {
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeClient() {
|
||||||
|
clearPersistTimer();
|
||||||
|
pendingClient = undefined;
|
||||||
|
removeStorageItem(storage, key);
|
||||||
|
},
|
||||||
|
flush,
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearPersistTimer();
|
||||||
|
pendingClient = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
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,
|
||||||
@@ -35,6 +36,13 @@ import { showWarningNotification } from "@homarr/notifications";
|
|||||||
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||||
|
|
||||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||||
|
import {
|
||||||
|
createSessionQueryPersister,
|
||||||
|
queryPersistenceBuster,
|
||||||
|
queryPersistenceMaxAgeMs,
|
||||||
|
shouldPersistDashboardQuery,
|
||||||
|
} from "./query-persistence";
|
||||||
|
import type { SessionQueryPersister } from "./query-persistence";
|
||||||
import { createQueryRetry } from "./query-retry";
|
import { createQueryRetry } from "./query-retry";
|
||||||
|
|
||||||
const DevelopmentTools =
|
const DevelopmentTools =
|
||||||
@@ -67,21 +75,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const sessionQueryScope = getSessionQueryScope(session);
|
const sessionQueryScope = getSessionQueryScope(session);
|
||||||
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
|
||||||
|
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
|
||||||
|
const handleScopeChange = useCallback(() => {
|
||||||
|
void queryPersister.removeClient();
|
||||||
|
reloadPage();
|
||||||
|
}, [queryPersister]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionQueryScopeGuard
|
<SessionQueryScopeGuard
|
||||||
initialScope={initialSessionQueryScope}
|
initialScope={initialSessionQueryScope}
|
||||||
currentScope={sessionQueryScope}
|
currentScope={sessionQueryScope}
|
||||||
onScopeChange={reloadPage}
|
onScopeChange={handleScopeChange}
|
||||||
>
|
>
|
||||||
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
|
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||||
</SessionQueryScopeGuard>
|
</SessionQueryScopeGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload();
|
const reloadPage = () => window.location.reload();
|
||||||
|
|
||||||
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
const ScopedTRPCReactProvider = ({
|
||||||
|
children,
|
||||||
|
queryPersister,
|
||||||
|
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||||
const wsClient = useMemo(
|
const wsClient = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createWSClient({
|
createWSClient({
|
||||||
@@ -136,6 +152,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => queryPersister.flush();
|
||||||
|
window.addEventListener("pagehide", flush);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", flush);
|
||||||
|
flush();
|
||||||
|
queryPersister.stop();
|
||||||
|
};
|
||||||
|
}, [queryPersister]);
|
||||||
useEffect(() => () => queryClient.clear(), [queryClient]);
|
useEffect(() => () => queryClient.clear(), [queryClient]);
|
||||||
|
|
||||||
const trpcClient = useMemo(() => {
|
const trpcClient = useMemo(() => {
|
||||||
@@ -186,10 +211,18 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: queryPersister,
|
||||||
|
maxAge: queryPersistenceMaxAgeMs,
|
||||||
|
buster: queryPersistenceBuster,
|
||||||
|
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</clientApi.Provider>
|
</clientApi.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -53,13 +53,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
|||||||
|
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoReadOnly && (
|
<TourTarget id="board-settings">
|
||||||
<TourTarget id="board-settings">
|
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
||||||
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
|
<IconSettings stroke={1.5} />
|
||||||
<IconSettings stroke={1.5} />
|
</HeaderButton>
|
||||||
</HeaderButton>
|
</TourTarget>
|
||||||
</TourTarget>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,9 +111,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
|
|
||||||
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
|
||||||
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
|
||||||
const sortedLayouts = form.values.layouts
|
|
||||||
.map((layout, index) => ({ layout, index }))
|
|
||||||
.toSorted((entryA, entryB) => entryA.layout.breakpoint - entryB.layout.breakpoint);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||||
@@ -129,25 +126,25 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
disabled={nextBreakpoint === null || !baseLayout}
|
disabled={nextBreakpoint === null || !baseLayout}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (nextBreakpoint === null || !baseLayout) return;
|
if (nextBreakpoint === null || !baseLayout) return;
|
||||||
form.setFieldValue("layouts", [
|
const newLayout: FormValues["layouts"][number] = {
|
||||||
...form.values.layouts,
|
id: createId(),
|
||||||
{
|
name: tBoard("setting.section.layout.custom.defaultName"),
|
||||||
id: createId(),
|
columnCount: baseLayout.columnCount,
|
||||||
name: tBoard("setting.section.layout.custom.defaultName"),
|
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
||||||
columnCount: baseLayout.columnCount,
|
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
||||||
leftGutterColumnCount: baseLayout.leftGutterColumnCount,
|
breakpoint: nextBreakpoint,
|
||||||
rightGutterColumnCount: baseLayout.rightGutterColumnCount,
|
role: "custom",
|
||||||
breakpoint: nextBreakpoint,
|
};
|
||||||
role: "custom",
|
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
||||||
},
|
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
||||||
]);
|
form.insertListItem("layouts", newLayout, insertionIndex);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tBoard("setting.section.layout.responsive.action.add")}
|
{tBoard("setting.section.layout.responsive.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{sortedLayouts.map(({ layout, index }) => {
|
{form.values.layouts.map((layout, index) => {
|
||||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||||
const customBreakpoints = form.values.layouts
|
const customBreakpoints = form.values.layouts
|
||||||
|
|||||||
@@ -184,10 +184,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widgetPanel[data-editing] .panelActions {
|
|
||||||
padding-inline-end: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 3.75rem;
|
min-height: 3.75rem;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica
|
|||||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context";
|
||||||
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||||
|
|
||||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.notebook.definition,
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -41,6 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||||
const result = getAssistantWidgetConfiguration(
|
const result = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.audioStats.definition,
|
||||||
{
|
{
|
||||||
boardId: "board-1",
|
boardId: "board-1",
|
||||||
boardName: "Home",
|
boardName: "Home",
|
||||||
@@ -88,11 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
|
|||||||
|
|
||||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||||
const mediaServer = getAssistantWidgetConfiguration(
|
const mediaServer = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.mediaServer.definition,
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const calendar = getAssistantWidgetConfiguration(
|
const calendar = getAssistantWidgetConfiguration(
|
||||||
|
widgetImports.calendar.definition,
|
||||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||||
settings,
|
settings,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { Suspense, use, useMemo } from "react";
|
||||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
@@ -13,7 +15,8 @@ import { useModalAction } from "@homarr/modals";
|
|||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import type { WidgetDefinition } from "@homarr/widgets/definition";
|
||||||
|
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
@@ -29,11 +32,11 @@ interface AssistantIntegration extends IntegrationSelectOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getAssistantWidgetConfiguration = (
|
export const getAssistantWidgetConfiguration = (
|
||||||
|
definition: WidgetDefinition,
|
||||||
args: ConfigureWidgetArgs,
|
args: ConfigureWidgetArgs,
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
integrations: AssistantIntegration[],
|
integrations: AssistantIntegration[],
|
||||||
) => {
|
) => {
|
||||||
const definition = widgetImports[args.kind].definition;
|
|
||||||
const integrationSupport = "supportedIntegrations" in definition;
|
const integrationSupport = "supportedIntegrations" in definition;
|
||||||
const supportedIntegrations = integrationSupport
|
const supportedIntegrations = integrationSupport
|
||||||
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
? (definition.supportedIntegrations as readonly IntegrationKind[])
|
||||||
@@ -54,35 +57,91 @@ export const getAssistantWidgetConfiguration = (
|
|||||||
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false),
|
||||||
value: {
|
value: {
|
||||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||||
options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options),
|
options: reduceWidgetOptionsWithDefinition(definition, settings, args.options),
|
||||||
integrationIds,
|
integrationIds,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AssistantConfigureWidgetTool = ({
|
type AssistantConfigureWidgetToolProps = ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>;
|
||||||
|
|
||||||
|
export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetToolProps) => {
|
||||||
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(props.status);
|
||||||
|
const completeArgs =
|
||||||
|
props.result === undefined && !hasFailedAssistantToolArguments(props.status) && hasCompleteArguments
|
||||||
|
? props.args
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!completeArgs) return <AssistantConfigureWidgetToolContent {...props} definition={undefined} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary FallbackComponent={AssistantWidgetDefinitionErrorFallback} resetKeys={[completeArgs.kind]}>
|
||||||
|
<Suspense fallback={<AssistantConfigureWidgetSkeleton />}>
|
||||||
|
<LoadedAssistantConfigureWidgetTool {...props} args={completeArgs} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantWidgetDefinitionErrorFallback = ({ resetErrorBoundary }: FallbackProps) => {
|
||||||
|
const t = useI18n("assistant.configureWidget");
|
||||||
|
const actionT = useI18n("common.action");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert color="red" variant="light" title={t("definitionLoadErrorTitle")} icon={<IconAlertTriangle size={18} />}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{t("definitionLoadErrorDescription")}</Text>
|
||||||
|
<Button variant="light" color="red" size="compact-sm" w="fit-content" onClick={resetErrorBoundary}>
|
||||||
|
{actionT("tryAgain")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadedAssistantConfigureWidgetTool = (
|
||||||
|
props: AssistantConfigureWidgetToolProps & { args: ConfigureWidgetArgs },
|
||||||
|
) => {
|
||||||
|
const definition = use(loadWidgetDefinition(props.args.kind));
|
||||||
|
return <AssistantConfigureWidgetToolContent {...props} definition={definition} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantConfigureWidgetSkeleton = () => {
|
||||||
|
const t = useI18n("assistant.configureWidget");
|
||||||
|
return (
|
||||||
|
<Box className={classes.appTool} aria-label={t("preparing")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Skeleton height={18} width="45%" />
|
||||||
|
<Skeleton height={14} width="76%" />
|
||||||
|
<Skeleton height={38} />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantConfigureWidgetToolContent = ({
|
||||||
args,
|
args,
|
||||||
result,
|
result,
|
||||||
addResult,
|
addResult,
|
||||||
status,
|
status,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
}: ToolCallMessagePartProps<ConfigureWidgetArgs, ConfigureWidgetResult>) => {
|
definition,
|
||||||
|
}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => {
|
||||||
const t = useI18n("assistant.configureWidget");
|
const t = useI18n("assistant.configureWidget");
|
||||||
const actionT = useI18n("common.action");
|
const actionT = useI18n("common.action");
|
||||||
const fullT = useI18n();
|
const fullT = useI18n();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||||
const definition = args?.kind ? widgetImports[args.kind].definition : undefined;
|
|
||||||
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition;
|
||||||
const integrations = clientApi.integration.all.useQuery(undefined, {
|
const integrations = clientApi.integration.all.useQuery(undefined, {
|
||||||
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
enabled: result === undefined && hasCompleteArguments && needsIntegrations,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const configuration = useMemo(() => {
|
const configuration = useMemo(() => {
|
||||||
if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null;
|
||||||
return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []);
|
return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []);
|
||||||
}, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
}, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]);
|
||||||
const missingRequiredIntegration =
|
const missingRequiredIntegration =
|
||||||
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0;
|
||||||
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
const { openModal: openWidgetModal } = useModalAction(WidgetEditModal);
|
||||||
@@ -134,15 +193,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) {
|
||||||
return (
|
return <AssistantConfigureWidgetSkeleton />;
|
||||||
<Box className={classes.appTool} aria-label={t("preparing")}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Skeleton height={18} width="45%" />
|
|
||||||
<Skeleton height={14} width="76%" />
|
|
||||||
<Skeleton height={38} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integrations.isError) {
|
if (integrations.isError) {
|
||||||
@@ -158,7 +209,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configuration) return null;
|
if (!configuration || !definition) return null;
|
||||||
|
|
||||||
const openConfigurationModal = (
|
const openConfigurationModal = (
|
||||||
value = configuration.value,
|
value = configuration.value,
|
||||||
@@ -167,7 +218,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
openWidgetModal(
|
openWidgetModal(
|
||||||
{
|
{
|
||||||
kind: args.kind,
|
kind: args.kind,
|
||||||
definition: widgetImports[args.kind].definition,
|
definition,
|
||||||
value,
|
value,
|
||||||
integrationData,
|
integrationData,
|
||||||
integrationSupport: configuration.integrationSupport,
|
integrationSupport: configuration.integrationSupport,
|
||||||
@@ -240,7 +291,7 @@ export const AssistantConfigureWidgetTool = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WidgetIcon = widgetImports[args.kind].definition.icon;
|
const WidgetIcon = definition.icon;
|
||||||
return (
|
return (
|
||||||
<Box className={classes.appTool}>
|
<Box className={classes.appTool}>
|
||||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
|
.refreshIndicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
|
position: absolute;
|
||||||
|
top: -34px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 201;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 120ms ease;
|
transition: opacity 120ms ease;
|
||||||
@@ -45,3 +63,11 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settingsButton {
|
||||||
|
right: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
import { Box, Button, Center, Loader, Portal } from "@mantine/core";
|
||||||
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
|
||||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
import { QueryErrorResetBoundary, useIsFetching, 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 type { FallbackProps } from "react-error-boundary";
|
import type { FallbackProps } from "react-error-boundary";
|
||||||
@@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors";
|
|||||||
import {
|
import {
|
||||||
createWidgetRuntimeState,
|
createWidgetRuntimeState,
|
||||||
getWidgetQueryKeys,
|
getWidgetQueryKeys,
|
||||||
|
getWidgetRuntimeQueries,
|
||||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||||
} from "@homarr/widgets/definition";
|
} from "@homarr/widgets/definition";
|
||||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||||
@@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions";
|
|||||||
import itemContentClasses from "./item-content.module.css";
|
import itemContentClasses from "./item-content.module.css";
|
||||||
import { WidgetContextMenu } from "./widget-context-menu";
|
import { WidgetContextMenu } from "./widget-context-menu";
|
||||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||||
|
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||||
|
|
||||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({
|
|||||||
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
|
||||||
[definition, item, settings],
|
[definition, item, settings],
|
||||||
);
|
);
|
||||||
|
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||||
|
const isWidgetRefreshing =
|
||||||
|
useIsFetching({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.state.data !== undefined &&
|
||||||
|
matchesWidgetItemQuery(
|
||||||
|
query.queryKey,
|
||||||
|
widgetQueryKeys,
|
||||||
|
{
|
||||||
|
itemId: item.id,
|
||||||
|
boardId: board.id,
|
||||||
|
integrationIds: item.integrationIds,
|
||||||
|
options: menuItem.options,
|
||||||
|
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||||
|
},
|
||||||
|
definition.queryMatcher,
|
||||||
|
),
|
||||||
|
}) > 0;
|
||||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||||
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||||
@@ -322,6 +342,11 @@ const LoadedBoardItemContent = ({
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</Box>
|
||||||
|
{isWidgetRefreshing && (
|
||||||
|
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</WidgetCardShell>
|
</WidgetCardShell>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -108,10 +108,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
|||||||
variant="default"
|
variant="default"
|
||||||
size={24}
|
size={24}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
pos="absolute"
|
|
||||||
top={4}
|
|
||||||
right={4}
|
|
||||||
style={{ zIndex: 30 }}
|
|
||||||
className={itemContentClasses.settingsButton}
|
className={itemContentClasses.settingsButton}
|
||||||
data-menu-open={isMenuOpen || undefined}
|
data-menu-open={isMenuOpen || undefined}
|
||||||
data-board-widget-settings
|
data-board-widget-settings
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 4px 8px;
|
padding: 4px 40px 4px 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
|
|||||||
+114
-28
@@ -1,5 +1,6 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { chromium, expect } from "@playwright/test";
|
import { chromium, expect } from "@playwright/test";
|
||||||
|
import type { Route } from "@playwright/test";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { stringify } from "superjson";
|
import { stringify } from "superjson";
|
||||||
import { describe, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
@@ -21,7 +22,9 @@ describe("lazy widget application graph", () => {
|
|||||||
const boardId = createId();
|
const boardId = createId();
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
const sectionId = createId();
|
const sectionId = createId();
|
||||||
const itemId = createId();
|
const clockItemId = createId();
|
||||||
|
const downloadsItemId = createId();
|
||||||
|
const mockIntegrationId = createId();
|
||||||
|
|
||||||
await db.insert(sqliteSchema.boards).values({
|
await db.insert(sqliteSchema.boards).values({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -41,26 +44,56 @@ describe("lazy widget application graph", () => {
|
|||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.items).values({
|
await db.insert(sqliteSchema.integrations).values({
|
||||||
id: itemId,
|
id: mockIntegrationId,
|
||||||
boardId,
|
name: "Mock downloads",
|
||||||
kind: "clock",
|
url: "http://mock.local",
|
||||||
options: stringify({ showSeconds: true }),
|
kind: "mock",
|
||||||
});
|
});
|
||||||
await db.insert(sqliteSchema.itemLayouts).values({
|
await db.insert(sqliteSchema.items).values([
|
||||||
itemId,
|
{
|
||||||
sectionId,
|
id: clockItemId,
|
||||||
layoutId,
|
boardId,
|
||||||
xOffset: 0,
|
kind: "clock",
|
||||||
yOffset: 0,
|
options: stringify({ showSeconds: true }),
|
||||||
width: 4,
|
},
|
||||||
height: 2,
|
{
|
||||||
|
id: downloadsItemId,
|
||||||
|
boardId,
|
||||||
|
kind: "downloads",
|
||||||
|
options: stringify({}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.itemLayouts).values([
|
||||||
|
{
|
||||||
|
itemId: clockItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
width: 4,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
sectionId,
|
||||||
|
layoutId,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 2,
|
||||||
|
width: 8,
|
||||||
|
height: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await db.insert(sqliteSchema.integrationItems).values({
|
||||||
|
itemId: downloadsItemId,
|
||||||
|
integrationId: mockIntegrationId,
|
||||||
});
|
});
|
||||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||||
|
|
||||||
const homarrContainer = await createHomarrContainer({
|
const homarrContainer = await createHomarrContainer({
|
||||||
environment: {
|
environment: {
|
||||||
AUTH_PROVIDERS: "credentials",
|
AUTH_PROVIDERS: "credentials",
|
||||||
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||||
},
|
},
|
||||||
mounts: {
|
mounts: {
|
||||||
"/appdata": localMountPath,
|
"/appdata": localMountPath,
|
||||||
@@ -69,10 +102,18 @@ describe("lazy widget application graph", () => {
|
|||||||
|
|
||||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const pageErrors: Error[] = [];
|
const pageErrors: Error[] = [];
|
||||||
|
const hydrationErrors: string[] = [];
|
||||||
page.on("pageerror", (error) => pageErrors.push(error));
|
page.on("pageerror", (error) => pageErrors.push(error));
|
||||||
|
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
|
||||||
|
hydrationErrors.push(message.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let releaseRefresh: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${baseUrl}/auth/login`);
|
await page.goto(`${baseUrl}/auth/login`);
|
||||||
@@ -88,9 +129,18 @@ describe("lazy widget application graph", () => {
|
|||||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||||
const clockWidget = page.locator(
|
const clockWidget = page.locator(
|
||||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
||||||
);
|
);
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
const downloadsWidget = page
|
||||||
|
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -101,23 +151,59 @@ describe("lazy widget application graph", () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||||
|
|
||||||
await expect
|
let refreshRequestStarted = false;
|
||||||
.poll(
|
const refreshGate = new Promise<void>((resolve) => {
|
||||||
async () =>
|
releaseRefresh = resolve;
|
||||||
await page.evaluate(async () => {
|
});
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
||||||
return registrations[0]?.scope ?? null;
|
const delayDownloadsRefresh = async (route: Route) => {
|
||||||
}),
|
refreshRequestStarted = true;
|
||||||
{ timeout: 15_000 },
|
await refreshGate;
|
||||||
)
|
await route.continue();
|
||||||
.toBe(`${baseUrl}/`);
|
};
|
||||||
|
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||||
|
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
|
||||||
|
|
||||||
|
const refreshedDownloadsResponse = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||||
|
);
|
||||||
|
releaseRefresh();
|
||||||
|
releaseRefresh = undefined;
|
||||||
|
await refreshedDownloadsResponse;
|
||||||
|
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
|
||||||
|
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||||
expect(pageErrors).toEqual([]);
|
expect(pageErrors).toEqual([]);
|
||||||
|
expect(hydrationErrors).toEqual([]);
|
||||||
|
|
||||||
|
const serviceWorkerContext = await browser.newContext();
|
||||||
|
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
||||||
|
try {
|
||||||
|
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
await serviceWorkerPage.evaluate(async () => {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
return registrations[0]?.scope ?? null;
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${baseUrl}/`);
|
||||||
|
} finally {
|
||||||
|
await serviceWorkerContext.close();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
releaseRefresh?.();
|
||||||
await browser.close();
|
await browser.close();
|
||||||
await homarrContainer.stop();
|
await homarrContainer.stop();
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,8 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5;
|
|||||||
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5;
|
||||||
|
|
||||||
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||||
|
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||||
|
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||||
|
|
||||||
export const isWidgetDataTrpcPath = (path: string) => {
|
export const isWidgetDataTrpcPath = (path: string) => {
|
||||||
|
|||||||
@@ -1904,8 +1904,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgetQuery": {
|
"widgetQuery": {
|
||||||
"loading": "Loading widget data",
|
"loading": "Loading widget data"
|
||||||
"stale": "Could not refresh {widget}; showing saved data"
|
|
||||||
},
|
},
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
"filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}",
|
||||||
@@ -7772,6 +7771,8 @@
|
|||||||
"ready": "Widget configuration ready",
|
"ready": "Widget configuration ready",
|
||||||
"cancelled": "Widget was not added",
|
"cancelled": "Widget was not added",
|
||||||
"automaticContinue": "Accepting the proposed widget configuration automatically",
|
"automaticContinue": "Accepting the proposed widget configuration automatically",
|
||||||
|
"definitionLoadErrorTitle": "Widget settings could not be loaded",
|
||||||
|
"definitionLoadErrorDescription": "Homarr could not load this widget's settings. Try again.",
|
||||||
"loadErrorTitle": "Integrations could not be loaded",
|
"loadErrorTitle": "Integrations could not be loaded",
|
||||||
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
|
||||||
"noIntegrationTitle": "A compatible integration is required",
|
"noIntegrationTitle": "A compatible integration is required",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MantineProviderProps } from "@mantine/core";
|
|||||||
|
|
||||||
import { theme } from "./theme";
|
import { theme } from "./theme";
|
||||||
|
|
||||||
export { theme } from "./theme";
|
export { scrollAreaDefaultProps, theme } from "./theme";
|
||||||
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
|
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
|
||||||
export * from "./components";
|
export * from "./components";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { createTheme, rem } from "@mantine/core";
|
|||||||
|
|
||||||
import { modalComponent } from "./theme/modal";
|
import { modalComponent } from "./theme/modal";
|
||||||
|
|
||||||
|
export const scrollAreaDefaultProps = {
|
||||||
|
type: "hover",
|
||||||
|
scrollbarSize: 2,
|
||||||
|
scrollHideDelay: 500,
|
||||||
|
offsetScrollbars: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const theme = createTheme({
|
export const theme = createTheme({
|
||||||
primaryColor: "red",
|
primaryColor: "red",
|
||||||
autoContrast: true,
|
autoContrast: true,
|
||||||
@@ -96,6 +103,12 @@ export const theme = createTheme({
|
|||||||
style: { borderRadius: 5 },
|
style: { borderRadius: 5 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ScrollArea: {
|
||||||
|
defaultProps: scrollAreaDefaultProps,
|
||||||
|
},
|
||||||
|
ScrollAreaAutosize: {
|
||||||
|
defaultProps: scrollAreaDefaultProps,
|
||||||
|
},
|
||||||
LoadingOverlay: {
|
LoadingOverlay: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export function JsonPathTreePicker({
|
|||||||
</Combobox.Header>
|
</Combobox.Header>
|
||||||
)}
|
)}
|
||||||
<Combobox.Options>
|
<Combobox.Options>
|
||||||
<ScrollArea.Autosize mah={420} type="scroll">
|
<ScrollArea.Autosize mah={420}>
|
||||||
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
|
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
|
||||||
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
|
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
|
||||||
{visibleNodes.map((node) => (
|
{visibleNodes.map((node) => (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from "./display";
|
} from "./display";
|
||||||
|
|
||||||
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
|
||||||
const t = useI18n("widget.airQuality");
|
|
||||||
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
const query = clientApi.widget.airQuality.atLocation.useQuery({
|
||||||
latitude: options.location.latitude,
|
latitude: options.location.latitude,
|
||||||
longitude: options.location.longitude,
|
longitude: options.location.longitude,
|
||||||
@@ -45,11 +44,6 @@ export default function AirQualityWidget({ options, width, height, displayMode }
|
|||||||
) : (
|
) : (
|
||||||
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
|
||||||
)}
|
)}
|
||||||
{query.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -177,7 +171,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
<ScrollArea h="100%" w="100%">
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import { storedContentToPlainText } from "./content";
|
import { storedContentToPlainText } from "./content";
|
||||||
|
|
||||||
@@ -239,7 +238,6 @@ const AnchorNoteWidgetContent = ({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<WidgetQueryErrorIndicator error={noteQuery.error} label={t("name")} />
|
|
||||||
{(isEditing || canEdit) && (
|
{(isEditing || canEdit) && (
|
||||||
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./app.module.css";
|
import classes from "./app.module.css";
|
||||||
import { PingDot } from "./ping/ping-dot";
|
import { PingDot } from "./ping/ping-dot";
|
||||||
@@ -24,7 +24,6 @@ import { PingIndicator } from "./ping/ping-indicator";
|
|||||||
|
|
||||||
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tApp = useI18n("widget.app");
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||||
@@ -127,11 +126,6 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
</AppLink>
|
</AppLink>
|
||||||
{appQuery.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={appQuery.error} label={tApp("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { getIconUrl } from "@homarr/definitions";
|
|||||||
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
|
|
||||||
export default function ArchiveTeamWarriorWidget({
|
export default function ArchiveTeamWarriorWidget({
|
||||||
integrationIds,
|
integrationIds,
|
||||||
@@ -50,7 +49,7 @@ const ArchiveTeamWarriorWidgetContent = ({
|
|||||||
const t = useI18n("widget.archiveTeamWarrior");
|
const t = useI18n("widget.archiveTeamWarrior");
|
||||||
const tWidgetCommon = useI18n("widget.common");
|
const tWidgetCommon = useI18n("widget.common");
|
||||||
const locale = useCurrentIntlLocale();
|
const locale = useCurrentIntlLocale();
|
||||||
const [data, statusQuery] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
||||||
|
|
||||||
const status = data.status;
|
const status = data.status;
|
||||||
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||||
@@ -74,7 +73,6 @@ const ArchiveTeamWarriorWidgetContent = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<WidgetQueryErrorIndicator error={statusQuery.error} label={t("name")} />
|
|
||||||
<Badge size="sm" color={getStatusColor(statusKey)}>
|
<Badge size="sm" color={getStatusColor(statusKey)}>
|
||||||
{t(`status.${statusKey}`)}
|
{t(`status.${statusKey}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { getQueryKey } from "@trpc/react-query";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { formatBytes, formatDuration } from "@homarr/common";
|
import { formatBytes, formatDuration } from "@homarr/common";
|
||||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { AudioStatsContent } from "./audio-stats-content";
|
import { AudioStatsContent } from "./audio-stats-content";
|
||||||
@@ -23,8 +21,6 @@ export default function AudioStatsWidget({
|
|||||||
displayMode = "compact",
|
displayMode = "compact",
|
||||||
widgetRuntimeRef,
|
widgetRuntimeRef,
|
||||||
}: WidgetComponentProps<"audioStats">) {
|
}: WidgetComponentProps<"audioStats">) {
|
||||||
const tAudio = useI18n("widget.audioStats");
|
|
||||||
const tMediaServer = useI18n("widget.mediaServer");
|
|
||||||
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
const statsInput = { integrationId: integrationIds[0] ?? "" };
|
||||||
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
|
||||||
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true };
|
||||||
@@ -56,9 +52,6 @@ export default function AudioStatsWidget({
|
|||||||
if (displayMode === "compact") {
|
if (displayMode === "compact") {
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
|
||||||
</Box>
|
|
||||||
{summary}
|
{summary}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -73,10 +66,8 @@ export default function AudioStatsWidget({
|
|||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="lg" p="md">
|
<Stack h="100%" gap="lg" p="md">
|
||||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||||
{(statsError || streamsError || hasStreamErrors) && (
|
{hasStreamErrors && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<WidgetQueryErrorIndicator error={statsError} label={tAudio("name")} />
|
|
||||||
<WidgetQueryErrorIndicator error={streamsError} label={tMediaServer("name")} />
|
|
||||||
<IntegrationErrorIndicator results={currentStreams} />
|
<IntegrationErrorIndicator results={currentStreams} />
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { getBeszelAlertsQueryInput } from "./display";
|
import { getBeszelAlertsQueryInput } from "./display";
|
||||||
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map";
|
||||||
@@ -126,7 +125,6 @@ export default function BeszelAlertsWidget({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||||
import { isBeszelGridMetricVisible } from "./display";
|
import { isBeszelGridMetricVisible } from "./display";
|
||||||
@@ -460,7 +459,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
displayMode,
|
displayMode,
|
||||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||||
const t = useI18n("widget.beszel");
|
const t = useI18n("widget.beszel");
|
||||||
const tFeature = useI18n("widget.beszelSystemGrid");
|
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||||
@@ -485,7 +483,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -514,7 +511,6 @@ export default function BeszelSystemGridWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={tFeature("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
|||||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||||
|
|
||||||
@@ -144,7 +143,6 @@ export default function BeszelSystemStatsWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={systemsResult} />
|
<IntegrationErrorIndicator results={systemsResult} />
|
||||||
</Group>
|
</Group>
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -184,7 +182,6 @@ export default function BeszelSystemStatsWidget({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={systemsResult} />
|
<IntegrationErrorIndicator results={systemsResult} />
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
} from "../beszel/_shared/format";
|
} from "../beszel/_shared/format";
|
||||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||||
import { getBeszelTableVisibleMetricKeys } from "./display";
|
import { getBeszelTableVisibleMetricKeys } from "./display";
|
||||||
@@ -453,7 +452,6 @@ export default function BeszelSystemTableWidget({
|
|||||||
<div style={{ position: "relative", height: "100%" }}>
|
<div style={{ position: "relative", height: "100%" }}>
|
||||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, str
|
|||||||
</Combobox.DropdownTarget>
|
</Combobox.DropdownTarget>
|
||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<ScrollArea.Autosize mah={280} type="auto">
|
<ScrollArea.Autosize mah={280}>
|
||||||
<Combobox.Options>
|
<Combobox.Options>
|
||||||
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
|
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
|
||||||
<Combobox.Option value={createOptionValue}>
|
<Combobox.Option value={createOptionValue}>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
import type { BookmarkItem } from "./bookmark-item";
|
import type { BookmarkItem } from "./bookmark-item";
|
||||||
@@ -43,14 +44,12 @@ export default function BookmarksWidget({
|
|||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const advanced = displayMode === "advanced";
|
const advanced = displayMode === "advanced";
|
||||||
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
|
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
|
||||||
const apps =
|
const appsQuery = clientApi.app.byIds.useQuery(appIds, {
|
||||||
getUsableWidgetQueryData(
|
select(selectedApps) {
|
||||||
clientApi.app.byIds.useQuery(appIds, {
|
return selectedApps;
|
||||||
select(selectedApps) {
|
},
|
||||||
return selectedApps;
|
});
|
||||||
},
|
const apps = getUsableWidgetQueryData(appsQuery) ?? [];
|
||||||
}),
|
|
||||||
) ?? [];
|
|
||||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||||
const configuredItems = options.items.flatMap((value) => {
|
const configuredItems = options.items.flatMap((value) => {
|
||||||
const directUrl = getDirectBookmarkUrl(value);
|
const directUrl = getDirectBookmarkUrl(value);
|
||||||
@@ -127,6 +126,8 @@ export default function BookmarksWidget({
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
|
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
|
||||||
{options.title.length > 0 ? (
|
{options.title.length > 0 ? (
|
||||||
@@ -148,11 +149,7 @@ export default function BookmarksWidget({
|
|||||||
</Center>
|
</Center>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
type="hover"
|
|
||||||
scrollbars={plan.horizontalScroll ? "x" : "y"}
|
scrollbars={plan.horizontalScroll ? "x" : "y"}
|
||||||
scrollbarSize={2}
|
|
||||||
scrollHideDelay={500}
|
|
||||||
offsetScrollbars
|
|
||||||
style={{ flex: 1, minHeight: 0 }}
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<Box miw="100%" mih="100%" pb={2}>
|
<Box miw="100%" mih="100%" pb={2}>
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export const CalendarEventList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
offsetScrollbars
|
|
||||||
pt={5}
|
pt={5}
|
||||||
w="100%"
|
w="100%"
|
||||||
h={fillHeight ? "100%" : undefined}
|
h={fillHeight ? "100%" : undefined}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { CalendarDay } from "./calender-day";
|
import { CalendarDay } from "./calender-day";
|
||||||
@@ -54,7 +54,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
|
|||||||
events={[]}
|
events={[]}
|
||||||
failedIntegrations={[]}
|
failedIntegrations={[]}
|
||||||
isPending={false}
|
isPending={false}
|
||||||
queryError={undefined}
|
|
||||||
month={month}
|
month={month}
|
||||||
setMonth={setMonth}
|
setMonth={setMonth}
|
||||||
/>
|
/>
|
||||||
@@ -100,13 +99,14 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
|
|||||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(calendarQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarBase
|
<CalendarBase
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
events={events}
|
events={events}
|
||||||
failedIntegrations={failedIntegrations}
|
failedIntegrations={failedIntegrations}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
queryError={calendarQuery.error}
|
|
||||||
month={month}
|
month={month}
|
||||||
setMonth={setMonth}
|
setMonth={setMonth}
|
||||||
options={options}
|
options={options}
|
||||||
@@ -120,7 +120,6 @@ interface CalendarBaseProps {
|
|||||||
events: CalendarEventWithSource[];
|
events: CalendarEventWithSource[];
|
||||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
queryError: unknown;
|
|
||||||
month: Date;
|
month: Date;
|
||||||
setMonth: (date: Date) => void;
|
setMonth: (date: Date) => void;
|
||||||
options: WidgetComponentProps<"calendar">["options"];
|
options: WidgetComponentProps<"calendar">["options"];
|
||||||
@@ -132,13 +131,11 @@ const CalendarBase = ({
|
|||||||
events,
|
events,
|
||||||
failedIntegrations,
|
failedIntegrations,
|
||||||
isPending,
|
isPending,
|
||||||
queryError,
|
|
||||||
month,
|
month,
|
||||||
setMonth,
|
setMonth,
|
||||||
options,
|
options,
|
||||||
displayMode,
|
displayMode,
|
||||||
}: CalendarBaseProps) => {
|
}: CalendarBaseProps) => {
|
||||||
const t = useI18n("widget.calendar");
|
|
||||||
const locale = useCurrentIntlLocale();
|
const locale = useCurrentIntlLocale();
|
||||||
const { firstDayOfWeek } = useSettings();
|
const { firstDayOfWeek } = useSettings();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
@@ -170,8 +167,6 @@ const CalendarBase = ({
|
|||||||
events={agendaEvents}
|
events={agendaEvents}
|
||||||
failedIntegrations={failedIntegrations}
|
failedIntegrations={failedIntegrations}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
queryError={queryError}
|
|
||||||
queryErrorLabel={t("name")}
|
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
month={month}
|
month={month}
|
||||||
@@ -180,8 +175,6 @@ const CalendarBase = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -194,13 +187,11 @@ const CalendarBase = ({
|
|||||||
date={month}
|
date={month}
|
||||||
maxLevel="month"
|
maxLevel="month"
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
static={isEditMode}
|
|
||||||
className={classes.calendar}
|
className={classes.calendar}
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
styles={{
|
styles={{
|
||||||
calendarHeaderControl: {
|
calendarHeaderControl: {
|
||||||
pointerEvents: isEditMode ? "none" : undefined,
|
|
||||||
borderRadius: "md",
|
borderRadius: "md",
|
||||||
height: isSmall ? "1.5rem" : undefined,
|
height: isSmall ? "1.5rem" : undefined,
|
||||||
width: isSmall ? "1.5rem" : undefined,
|
width: isSmall ? "1.5rem" : undefined,
|
||||||
@@ -249,17 +240,16 @@ const CalendarBase = ({
|
|||||||
<CalendarDay
|
<CalendarDay
|
||||||
date={dayjs(tileDate).toDate()}
|
date={dayjs(tileDate).toDate()}
|
||||||
events={eventsForDate}
|
events={eventsForDate}
|
||||||
disabled={isEditMode || eventsForDate.length === 0}
|
disabled={eventsForDate.length === 0}
|
||||||
rootWidth={width}
|
rootWidth={width}
|
||||||
rootHeight={height}
|
rootHeight={height}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hasErrors && (
|
{failedIntegrations.length > 0 && (
|
||||||
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
<Group className={classes.errorIndicator} gap={0} pos="absolute">
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -270,8 +260,6 @@ interface CalendarAgendaProps {
|
|||||||
events: CalendarEventWithSource[];
|
events: CalendarEventWithSource[];
|
||||||
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
failedIntegrations: CalendarBaseProps["failedIntegrations"];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
queryError: unknown;
|
|
||||||
queryErrorLabel: string;
|
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
month: Date;
|
month: Date;
|
||||||
@@ -282,8 +270,6 @@ const CalendarAgenda = ({
|
|||||||
events,
|
events,
|
||||||
failedIntegrations,
|
failedIntegrations,
|
||||||
isPending,
|
isPending,
|
||||||
queryError,
|
|
||||||
queryErrorLabel,
|
|
||||||
isEditMode,
|
isEditMode,
|
||||||
locale,
|
locale,
|
||||||
month,
|
month,
|
||||||
@@ -331,7 +317,6 @@ const CalendarAgenda = ({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ export const AdvancedClockView = ({
|
|||||||
<ScrollArea
|
<ScrollArea
|
||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
type="auto"
|
|
||||||
offsetScrollbars
|
|
||||||
scrollbarSize={6}
|
|
||||||
aria-label={t("worldClock.advancedLabel")}
|
aria-label={t("worldClock.advancedLabel")}
|
||||||
>
|
>
|
||||||
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
|
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||||
import { WeatherDescription } from "../weather/icon";
|
import { WeatherDescription } from "../weather/icon";
|
||||||
|
|
||||||
@@ -25,17 +24,16 @@ export const ClockWeatherSummary = ({
|
|||||||
animateIcon,
|
animateIcon,
|
||||||
detailed,
|
detailed,
|
||||||
}: ClockWeatherSummaryProps) => {
|
}: ClockWeatherSummaryProps) => {
|
||||||
const t = useI18n();
|
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||||
const weather = weatherQuery.data;
|
const weather = weatherQuery.data;
|
||||||
|
|
||||||
if (!weather) {
|
if (!weather) {
|
||||||
return isInitialWidgetQueryPending(weatherQuery) ? (
|
if (isInitialWidgetQueryPending(weatherQuery)) {
|
||||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
return <Loader size="xs" aria-label={tCommon("action.loading")} />;
|
||||||
) : (
|
}
|
||||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||||
@@ -72,7 +70,6 @@ export const ClockWeatherSummary = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -22,6 +22,16 @@
|
|||||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.homarr-data-table th {
|
.homarr-data-table th {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
|
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
|
||||||
import { DataTable } from "mantine-datatable";
|
import { DataTable } from "mantine-datatable";
|
||||||
|
|
||||||
|
import { scrollAreaDefaultProps } from "@homarr/ui";
|
||||||
|
|
||||||
const fallbackColumnWidth = 60;
|
const fallbackColumnWidth = 60;
|
||||||
|
|
||||||
const getColumnWidth = (width: string | number | undefined): number => {
|
const getColumnWidth = (width: string | number | undefined): number => {
|
||||||
@@ -149,11 +151,11 @@ export function HomarrDataTable<T>({
|
|||||||
defaultColumnProps={{
|
defaultColumnProps={{
|
||||||
...defaultColumnProps,
|
...defaultColumnProps,
|
||||||
noWrap: defaultColumnProps?.noWrap ?? true,
|
noWrap: defaultColumnProps?.noWrap ?? true,
|
||||||
draggable: !isEditMode,
|
draggable: true,
|
||||||
resizable: !isEditMode,
|
resizable: true,
|
||||||
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
||||||
}}
|
}}
|
||||||
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
|
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,10 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator";
|
import { WidgetQueryLoadingState } from "./query-state-indicator";
|
||||||
|
|
||||||
vi.mock("@homarr/translation/client", () => ({
|
vi.mock("@homarr/translation/client", () => ({
|
||||||
useI18n: () => (key: string, values?: { widget?: string }) => {
|
useI18n: () => (key: string) => {
|
||||||
if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`;
|
|
||||||
if (key === "loading") return "Loading widget data";
|
if (key === "loading") return "Loading widget data";
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
@@ -26,7 +25,7 @@ const matchMedia = (query: string) => ({
|
|||||||
dispatchEvent: () => false,
|
dispatchEvent: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WidgetQueryErrorIndicator", () => {
|
describe("WidgetQueryLoadingState", () => {
|
||||||
let host: HTMLDivElement | undefined;
|
let host: HTMLDivElement | undefined;
|
||||||
let root: ReturnType<typeof createRoot> | undefined;
|
let root: ReturnType<typeof createRoot> | undefined;
|
||||||
|
|
||||||
@@ -36,28 +35,6 @@ describe("WidgetQueryErrorIndicator", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses a localized label without exposing the raw failure", async () => {
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("matchMedia", matchMedia);
|
|
||||||
host = document.createElement("div");
|
|
||||||
document.body.append(host);
|
|
||||||
root = createRoot(host);
|
|
||||||
const secret = "http://admin:password@private.local/path?token=secret";
|
|
||||||
|
|
||||||
await act(async () =>
|
|
||||||
root?.render(
|
|
||||||
createElement(
|
|
||||||
MantineProvider,
|
|
||||||
null,
|
|
||||||
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull();
|
|
||||||
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("announces the loading state to assistive technology", async () => {
|
test("announces the loading state to assistive technology", async () => {
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
vi.stubGlobal("matchMedia", matchMedia);
|
vi.stubGlobal("matchMedia", matchMedia);
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core";
|
import { Center, Loader, VisuallyHidden } from "@mantine/core";
|
||||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
interface WidgetQueryErrorIndicatorProps {
|
|
||||||
error: unknown;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
|
|
||||||
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
|
|
||||||
const t = useI18n("common.widgetQuery");
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
const accessibleLabel = t("stale", { widget: label });
|
|
||||||
return (
|
|
||||||
<Tooltip label={accessibleLabel} position="left" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
component="span"
|
|
||||||
color="orange"
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
role="note"
|
|
||||||
aria-label={accessibleLabel}
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ cursor: "help" }}
|
|
||||||
>
|
|
||||||
<IconAlertTriangle aria-hidden size="var(--mantine-font-size-sm)" />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WidgetQueryLoadingState() {
|
export function WidgetQueryLoadingState() {
|
||||||
const t = useI18n("common.widgetQuery");
|
const t = useI18n("common.widgetQuery");
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
|
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
|
||||||
@@ -148,9 +146,6 @@ const FailedSourceBadges = ({
|
|||||||
{t("source.unavailable", { source: source.integrationName })}
|
{t("source.unavailable", { source: source.integrationName })}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
<Box ml="auto">
|
|
||||||
<WidgetQueryErrorIndicator error={queryError} label={getIntegrationName("coolify")} />
|
|
||||||
</Box>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -184,7 +184,7 @@ export default function CountdownWidget({
|
|||||||
onNotificationsChange={setNotifications}
|
onNotificationsChange={setNotifications}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea type="auto" style={{ flex: 1, minHeight: 0 }}>
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Stack gap="md" pb="xs">
|
<Stack gap="md" pb="xs">
|
||||||
<EventSection
|
<EventSection
|
||||||
title={t("upcoming")}
|
title={t("upcoming")}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
|||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import TimerModal from "./TimerModal";
|
import TimerModal from "./TimerModal";
|
||||||
@@ -68,7 +68,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
|
||||||
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||||
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
const summaries = summaryResults.filter(isAvailableDnsSummaryResult);
|
||||||
const { isPending: isSummaryPending } = summaryQuery;
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -123,7 +122,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const t = useI18n("widget.dnsHoleControls");
|
const t = useI18n("widget.dnsHoleControls");
|
||||||
const tCommon = useI18n("common");
|
|
||||||
|
|
||||||
// Timer modal setup
|
// Timer modal setup
|
||||||
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
||||||
@@ -142,15 +140,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
setBulkPending(false);
|
setBulkPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSummaryPending) {
|
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
|
||||||
return (
|
|
||||||
<Stack h="100%" justify="center" align="center">
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{tCommon("action.loading")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -165,7 +155,6 @@ export default function DnsHoleControlsWidget({
|
|||||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={summaryResults} />
|
<IntegrationErrorIndicator results={summaryResults} />
|
||||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
{controlAllButtonsVisible && (
|
{controlAllButtonsVisible && (
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
|
|
||||||
export default function DnsHoleSummaryWidget({
|
export default function DnsHoleSummaryWidget({
|
||||||
options,
|
options,
|
||||||
@@ -44,10 +44,8 @@ export default function DnsHoleSummaryWidget({
|
|||||||
integrationIds,
|
integrationIds,
|
||||||
});
|
});
|
||||||
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
|
||||||
const { isPending } = summaryQuery;
|
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const tDns = useI18n("widget.dnsHoleSummary");
|
const tDns = useI18n("widget.dnsHoleSummary");
|
||||||
const tWidgetCommon = useI18n("widget.common");
|
const tWidgetCommon = useI18n("widget.common");
|
||||||
|
|
||||||
@@ -56,22 +54,13 @@ export default function DnsHoleSummaryWidget({
|
|||||||
const layoutProps = boxPropsByLayout(options.layout);
|
const layoutProps = boxPropsByLayout(options.layout);
|
||||||
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
|
||||||
|
|
||||||
if (isPending) {
|
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
|
||||||
return (
|
|
||||||
<Stack h="100%" justify="center" align="center">
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{tCommon("action.loading")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap={0} pos="relative">
|
<Stack h="100%" gap={0} pos="relative">
|
||||||
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={summaries} />
|
<IntegrationErrorIndicator results={summaries} />
|
||||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={tDns("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||||
@@ -104,7 +93,7 @@ export default function DnsHoleSummaryWidget({
|
|||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
{showSourceStatuses && (
|
{showSourceStatuses && (
|
||||||
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
|
<ScrollArea px="xs" pb="xs">
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
{successfulSummaries.map(({ integration, summary }) => (
|
{successfulSummaries.map(({ integration, summary }) => (
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||||
import { formatLocalizedDateTime } from "../common/locale";
|
import { formatLocalizedDateTime } from "../common/locale";
|
||||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||||
@@ -314,7 +314,6 @@ export default function DownloadClientsWidget({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={currentItems ?? []} />
|
<IntegrationErrorIndicator results={currentItems ?? []} />
|
||||||
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -743,6 +742,8 @@ export default function DownloadClientsWidget({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(downloadsQuery)) return <WidgetQueryLoadingState />;
|
||||||
|
|
||||||
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
||||||
if (isEditMode) rowContextMenuHandler = undefined;
|
if (isEditMode) rowContextMenuHandler = undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Accordion, Box, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||||
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { formatUptime } from "../system-health";
|
import { formatUptime } from "../system-health";
|
||||||
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
|
import { getClusterAccordionDefault, getClusterVisibleSections } from "./accordion-state";
|
||||||
@@ -71,9 +70,6 @@ export const ClusterHealthMonitoring = ({
|
|||||||
const isTiny = displayMode !== "advanced" && width < 256;
|
const isTiny = displayMode !== "advanced" && width < 256;
|
||||||
return (
|
return (
|
||||||
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
|
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
{(isAdvanced || options.showUptime) && !isTiny && (
|
{(isAdvanced || options.showUptime) && !isTiny && (
|
||||||
<Group justify="center" wrap="nowrap">
|
<Group justify="center" wrap="nowrap">
|
||||||
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
<Text fz="xs" fw={700} c="dimmed" ta="center">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-stor
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import { CpuRing } from "./rings/cpu-ring";
|
import { CpuRing } from "./rings/cpu-ring";
|
||||||
@@ -81,7 +81,6 @@ export const SystemHealthMonitoring = ({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={healthResults} />
|
<IntegrationErrorIndicator results={healthResults} />
|
||||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { WidgetEmptyState } from "../../common/empty-state";
|
|||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { useWidgetRuntimeActions } from "../../runtime-hooks";
|
import { useWidgetRuntimeActions } from "../../runtime-hooks";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const t = useI18n("widget.immich-albumCarousel");
|
|
||||||
|
|
||||||
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
|
const albumQuery = clientApi.widget.immich.getAlbum.useQuery(
|
||||||
{
|
{
|
||||||
@@ -71,9 +69,6 @@ export default function ImmichAlbumCarouselWidget({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 3 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={albumQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
{album.assets.length === 0 || photoAssets.length === 0 ? (
|
{album.assets.length === 0 || photoAssets.length === 0 ? (
|
||||||
<NoPhotosInAlbum />
|
<NoPhotosInAlbum />
|
||||||
) : (
|
) : (
|
||||||
@@ -228,7 +223,7 @@ function Carousel({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{advanced && (
|
{advanced && (
|
||||||
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}>
|
<ScrollArea px="xs" py={6}>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
{assets.map((asset, index) => (
|
{assets.map((asset, index) => (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||||
import { getQueryKey } from "@trpc/react-query";
|
import { getQueryKey } from "@trpc/react-query";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -103,11 +103,6 @@ export default function ImmichServerStatsWidget({
|
|||||||
return (
|
return (
|
||||||
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
|
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
|
||||||
{statsContent}
|
{statsContent}
|
||||||
{statsQuery.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -121,15 +116,10 @@ export default function ImmichServerStatsWidget({
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
{t("albumLimit", { count: MAX_ADVANCED_ALBUMS })}
|
||||||
</Text>
|
</Text>
|
||||||
<WidgetQueryErrorIndicator error={statsQuery.error ?? albumsQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
{isInitialWidgetQueryPending(albumsQuery) ? (
|
{isInitialWidgetQueryPending(albumsQuery) ? (
|
||||||
<WidgetQueryLoadingState />
|
<WidgetQueryLoadingState />
|
||||||
) : albumsQuery.error && albumsQuery.data === undefined ? (
|
|
||||||
<Center h="100%" p="md">
|
|
||||||
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("name")} />
|
|
||||||
</Center>
|
|
||||||
) : albums.length === 0 ? (
|
) : albums.length === 0 ? (
|
||||||
<WidgetEmptyState />
|
<WidgetEmptyState />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type { Loader } from "next/dynamic";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Center, Loader as UiLoader } from "@mantine/core";
|
import { Center, Loader as UiLoader } from "@mantine/core";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
|
||||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
|
||||||
|
|
||||||
import * as anchorNote from "./anchor-note";
|
import * as anchorNote from "./anchor-note";
|
||||||
import * as audioStats from "./audio-stats";
|
import * as audioStats from "./audio-stats";
|
||||||
@@ -41,7 +39,6 @@ import * as notebook from "./notebook";
|
|||||||
import * as paperlessNgx from "./paperless-ngx";
|
import * as paperlessNgx from "./paperless-ngx";
|
||||||
import * as patchmon from "./patchmon";
|
import * as patchmon from "./patchmon";
|
||||||
import * as notifications from "./notifications";
|
import * as notifications from "./notifications";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
|
||||||
import * as releases from "./releases";
|
import * as releases from "./releases";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
@@ -191,19 +188,3 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
|
|||||||
}
|
}
|
||||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||||
: never[])[number];
|
: never[])[number];
|
||||||
|
|
||||||
export const reduceWidgetOptionsWithDefaultValues = (
|
|
||||||
kind: WidgetKind,
|
|
||||||
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
|
||||||
currentValue: Record<string, unknown> = {},
|
|
||||||
) => {
|
|
||||||
const definition = widgetImports[kind].definition;
|
|
||||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
|
||||||
return objectEntries(options).reduce(
|
|
||||||
(prev, [key, value]) => ({
|
|
||||||
...prev,
|
|
||||||
[key]: currentValue[key] ?? value.defaultValue,
|
|
||||||
}),
|
|
||||||
{} as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -29,7 +29,6 @@ import { useWidgetRuntimeActions } from "../runtime-hooks";
|
|||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import actionTargetClasses from "../common/action-target.module.css";
|
import actionTargetClasses from "../common/action-target.module.css";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import { getIndexerDisplayStatus } from "./status";
|
import { getIndexerDisplayStatus } from "./status";
|
||||||
@@ -118,7 +117,6 @@ export default function IndexerManagerWidget({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<WidgetQueryErrorIndicator error={indexersQuery.error} label={t("title")} />
|
|
||||||
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
<Tooltip label={testAllError ? tCommon("error") : t("testAll")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
className={combineClasses("indexer-manager-test-action-icon", classes.testAction, actionTargetClasses.root)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
@@ -126,7 +125,6 @@ export default function MediaMissingWidget({
|
|||||||
) => (
|
) => (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
h="100%"
|
h="100%"
|
||||||
scrollbarSize={4}
|
|
||||||
startScrollPosition={tabScrollPositionsRef.current[tab]}
|
startScrollPosition={tabScrollPositionsRef.current[tab]}
|
||||||
onScrollPositionChange={(position) => {
|
onScrollPositionChange={(position) => {
|
||||||
tabScrollPositionsRef.current[tab] = position;
|
tabScrollPositionsRef.current[tab] = position;
|
||||||
@@ -164,15 +162,10 @@ export default function MediaMissingWidget({
|
|||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
|
|
||||||
|
|
||||||
if (isAdvanced) {
|
if (isAdvanced) {
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap={0}>
|
<Stack h="100%" gap={0}>
|
||||||
<Group justify="flex-end" px="xs">
|
{partialFailures}
|
||||||
{queryFailure}
|
|
||||||
{partialFailures}
|
|
||||||
</Group>
|
|
||||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||||
{showMissing && (
|
{showMissing && (
|
||||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||||
@@ -211,10 +204,7 @@ export default function MediaMissingWidget({
|
|||||||
h="100%"
|
h="100%"
|
||||||
style={{ display: "flex", flexDirection: "column" }}
|
style={{ display: "flex", flexDirection: "column" }}
|
||||||
>
|
>
|
||||||
<Group justify="flex-end" px="xs">
|
{partialFailures}
|
||||||
{queryFailure}
|
|
||||||
{partialFailures}
|
|
||||||
</Group>
|
|
||||||
<Tabs.List grow>
|
<Tabs.List grow>
|
||||||
{showMissing && (
|
{showMissing && (
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import type { WidgetComponentProps } from "../definition";
|
|||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
export default function MediaReleasesWidget({
|
export default function MediaReleasesWidget({
|
||||||
@@ -43,7 +43,6 @@ export default function MediaReleasesWidget({
|
|||||||
height,
|
height,
|
||||||
displayMode,
|
displayMode,
|
||||||
}: WidgetComponentProps<"mediaReleases">) {
|
}: WidgetComponentProps<"mediaReleases">) {
|
||||||
const t = useI18n("widget.mediaReleases");
|
|
||||||
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
|
||||||
const response = getUsableWidgetQueryData(releasesQuery);
|
const response = getUsableWidgetQueryData(releasesQuery);
|
||||||
|
|
||||||
@@ -70,10 +69,9 @@ export default function MediaReleasesWidget({
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
{(failedIntegrations.length > 0 || releasesQuery.error) && (
|
{failedIntegrations.length > 0 && (
|
||||||
<Group pos="absolute" top={4} right={4} gap={0}>
|
<Group pos="absolute" top={4} right={4} gap={0}>
|
||||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||||
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -35,17 +36,17 @@ export default function MediaServerWidget({
|
|||||||
.filter(({ id }) => integrationIds.includes(id))
|
.filter(({ id }) => integrationIds.includes(id))
|
||||||
.map(({ id }) => id),
|
.map(({ id }) => id),
|
||||||
);
|
);
|
||||||
const mediaRequestData = getUsableWidgetQueryData(
|
const mediaRequestQuery = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||||
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
integrationIds,
|
||||||
integrationIds,
|
statuses:
|
||||||
statuses:
|
options.statusFilter.length > 0
|
||||||
options.statusFilter.length > 0
|
? options.statusFilter
|
||||||
? options.statusFilter
|
: ["pending", "approved", "declined", "failed", "completed"],
|
||||||
: ["pending", "approved", "declined", "failed", "completed"],
|
recentDays: options.recentDays,
|
||||||
recentDays: options.recentDays,
|
});
|
||||||
}),
|
const mediaRequestData = getUsableWidgetQueryData(mediaRequestQuery);
|
||||||
);
|
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(mediaRequestQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||||
@@ -61,7 +62,6 @@ export default function MediaServerWidget({
|
|||||||
)}
|
)}
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="mediaRequests-list-scrollArea"
|
className="mediaRequests-list-scrollArea"
|
||||||
scrollbarSize="md"
|
|
||||||
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../../common/empty-state";
|
import { WidgetEmptyState } from "../../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
|
||||||
|
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
|
||||||
import actionTargetClasses from "../../common/action-target.module.css";
|
import actionTargetClasses from "../../common/action-target.module.css";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -42,10 +43,12 @@ export default function MediaServerWidget({
|
|||||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||||
const t = useI18n("widget.mediaRequests-requestStats");
|
const t = useI18n("widget.mediaRequests-requestStats");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }));
|
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
|
||||||
|
const requestStats = getUsableWidgetQueryData(requestStatsQuery);
|
||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
if (isInitialWidgetQueryPending(requestStatsQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!requestStats) return <WidgetEmptyState />;
|
if (!requestStats) return <WidgetEmptyState />;
|
||||||
if (
|
if (
|
||||||
requestStats.users.length === 0 &&
|
requestStats.users.length === 0 &&
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
|
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
|
||||||
@@ -262,14 +261,12 @@ export default function MediaServerWidget({
|
|||||||
aria-label={tSearch("placeholder")}
|
aria-label={tSearch("placeholder")}
|
||||||
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
|
leftSection={<IconSearch size="var(--mantine-font-size-xs)" aria-hidden />}
|
||||||
/>
|
/>
|
||||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={currentStreams} />
|
<IntegrationErrorIndicator results={currentStreams} />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
|
{!isAdvanced && hasFailedIntegrations && (
|
||||||
<Group px="xs" justify="flex-end">
|
<Group px="xs" justify="flex-end">
|
||||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
|
||||||
<IntegrationErrorIndicator results={currentStreams} />
|
<IntegrationErrorIndicator results={currentStreams} />
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { getQueryKey } from "@trpc/react-query";
|
import { getQueryKey } from "@trpc/react-query";
|
||||||
import {
|
import {
|
||||||
Box,
|
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
@@ -26,7 +25,6 @@ import { views } from ".";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||||
import { HealthCheckStatus } from "./health-check-status";
|
import { HealthCheckStatus } from "./health-check-status";
|
||||||
import { QueuePanel } from "./panels/queue.panel";
|
import { QueuePanel } from "./panels/queue.panel";
|
||||||
@@ -115,16 +113,9 @@ export default function MediaTranscodingWidget({
|
|||||||
const queuePageCount = totalQueuePages ?? 1;
|
const queuePageCount = totalQueuePages ?? 1;
|
||||||
const isTiny = !isAdvanced && (width < 280 || height < 140);
|
const isTiny = !isAdvanced && (width < 280 || height < 140);
|
||||||
const footerLayout = getTranscodingFooterLayout(width, height);
|
const footerLayout = getTranscodingFooterLayout(width, height);
|
||||||
const queryIndicator = (
|
|
||||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
|
||||||
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAdvanced) {
|
if (isAdvanced) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
<Stack gap="xs" h="100%" p="xs" pos="relative">
|
||||||
{queryIndicator}
|
|
||||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||||
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
|
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
|
||||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||||
@@ -155,7 +146,6 @@ export default function MediaTranscodingWidget({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={4} h="100%" pos="relative">
|
<Stack gap={4} h="100%" pos="relative">
|
||||||
{queryIndicator}
|
|
||||||
{view === "workers" ? (
|
{view === "workers" ? (
|
||||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
|
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
|
||||||
) : view === "queue" ? (
|
) : view === "queue" ? (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
|
||||||
import { getNetworkControllerStatusLayout } from "./layout";
|
import { getNetworkControllerStatusLayout } from "./layout";
|
||||||
import { WifiVariant } from "./variants/wifi-variant";
|
import { WifiVariant } from "./variants/wifi-variant";
|
||||||
import { WiredVariant } from "./variants/wired-variant";
|
import { WiredVariant } from "./variants/wired-variant";
|
||||||
@@ -35,7 +34,6 @@ export default function NetworkControllerNetworkStatusWidget({
|
|||||||
),
|
),
|
||||||
[results],
|
[results],
|
||||||
);
|
);
|
||||||
const t = useI18n("widget.networkControllerStatus");
|
|
||||||
const tWidgetCommon = useI18n("widget.common");
|
const tWidgetCommon = useI18n("widget.common");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const locale = useCurrentIntlLocale();
|
const locale = useCurrentIntlLocale();
|
||||||
@@ -49,7 +47,6 @@ export default function NetworkControllerNetworkStatusWidget({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0} justify="flex-end">
|
<Group gap={0} justify="flex-end">
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { WidgetComponentProps } from "../../definition";
|
|||||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||||
import { getBinaryStatusKey } from "../../common/locale";
|
import { getBinaryStatusKey } from "../../common/locale";
|
||||||
import { getUsableWidgetQueryData } from "../../common/query-state";
|
import { getUsableWidgetQueryData } from "../../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator";
|
|
||||||
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
|
import type { NetworkControllerMatrixMetricKey, NetworkControllerMatrixSectionKey } from "./display";
|
||||||
import { getNetworkControllerMatrix } from "./display";
|
import { getNetworkControllerMatrix } from "./display";
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ export default function NetworkControllerSummaryWidget({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0} justify="flex-end">
|
<Group gap={0} justify="flex-end">
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -500,7 +500,6 @@ export function Notebook({
|
|||||||
|
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
mih="4rem"
|
mih="4rem"
|
||||||
offsetScrollbars
|
|
||||||
pl={12}
|
pl={12}
|
||||||
pt={12}
|
pt={12}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getNotificationDisplay } from "./display";
|
import { getNotificationDisplay } from "./display";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
@@ -67,11 +66,6 @@ export default function NotificationsWidget({
|
|||||||
return (
|
return (
|
||||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||||
<Stack w="100%" gap="xs">
|
<Stack w="100%" gap="xs">
|
||||||
{notificationsQuery.error && (
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<WidgetQueryErrorIndicator error={notificationsQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{failedIntegrations.length > 0 && (
|
{failedIntegrations.length > 0 && (
|
||||||
<Group gap={4} wrap="wrap">
|
<Group gap={4} wrap="wrap">
|
||||||
{failedIntegrations.map((integration) => (
|
{failedIntegrations.map((integration) => (
|
||||||
|
|||||||
@@ -29,13 +29,6 @@
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queryIndicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactRoot {
|
.compactRoot {
|
||||||
padding: clamp(4px, 3cqi, 8px);
|
padding: clamp(4px, 3cqi, 8px);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ import dayjs from "dayjs";
|
|||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import type { TablerIcon } from "@homarr/ui";
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
|
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
|
||||||
@@ -278,9 +277,6 @@ export default function PatchMonWidget({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<div className={classes.root}>
|
||||||
<div className={classes.queryIndicator}>
|
|
||||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={getIntegrationName("patchmon")} />
|
|
||||||
</div>
|
|
||||||
{showHero && (
|
{showHero && (
|
||||||
<ComplianceHero
|
<ComplianceHero
|
||||||
compliancePercent={compliancePercent}
|
compliancePercent={compliancePercent}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { AveragesSection } from "./averages";
|
import { AveragesSection } from "./averages";
|
||||||
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
|
import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers";
|
||||||
@@ -72,7 +71,6 @@ export default function SpeedtestTrackerWidget({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
@@ -131,7 +129,6 @@ export default function SpeedtestTrackerWidget({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
|
||||||
{dashboardData.length > 1 && (
|
{dashboardData.length > 1 && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
function round(value: number) {
|
function round(value: number) {
|
||||||
@@ -196,11 +196,6 @@ export default function StockPriceWidget({
|
|||||||
return (
|
return (
|
||||||
<Box h="100%" w="100%" pos="relative">
|
<Box h="100%" w="100%" pos="relative">
|
||||||
{content}
|
{content}
|
||||||
{stockQuery.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator error={stockQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
@@ -253,7 +253,6 @@ export default function SystemResources({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
|
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
|
||||||
import { SystemResourceCPUChart } from "./chart/cpu-chart";
|
import { SystemResourceCPUChart } from "./chart/cpu-chart";
|
||||||
import { SystemResourceGPUChart } from "./chart/gpu-chart";
|
import { SystemResourceGPUChart } from "./chart/gpu-chart";
|
||||||
@@ -84,7 +82,6 @@ export default function SystemResources({
|
|||||||
height,
|
height,
|
||||||
displayMode,
|
displayMode,
|
||||||
}: WidgetComponentProps<"systemResources">) {
|
}: WidgetComponentProps<"systemResources">) {
|
||||||
const t = useI18n("widget.systemResources");
|
|
||||||
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
|
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
|
||||||
const results = getUsableWidgetQueryData(healthQuery) ?? [];
|
const results = getUsableWidgetQueryData(healthQuery) ?? [];
|
||||||
const data = results.filter(
|
const data = results.filter(
|
||||||
@@ -98,7 +95,6 @@ export default function SystemResources({
|
|||||||
const queryIndicators = (
|
const queryIndicators = (
|
||||||
<Group gap={0}>
|
<Group gap={0}>
|
||||||
<IntegrationErrorIndicator results={results} />
|
<IntegrationErrorIndicator results={results} />
|
||||||
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ const AdvancedTimer = ({
|
|||||||
const t = useI18n("widget.timer");
|
const t = useI18n("widget.timer");
|
||||||
const phaseLabel = getTimerPhaseLabel(runtime, t);
|
const phaseLabel = getTimerPhaseLabel(runtime, t);
|
||||||
return (
|
return (
|
||||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
<ScrollArea h="100%" w="100%">
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
|
|||||||
@@ -3,14 +3,12 @@
|
|||||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
import type { TracearrDashboardData } from "@homarr/integrations/types";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
import { formatTotalTracearrBitrate } from "./bitrate";
|
import { formatTotalTracearrBitrate } from "./bitrate";
|
||||||
import { RecentActivityList } from "./recent-activity-section";
|
import { RecentActivityList } from "./recent-activity-section";
|
||||||
@@ -186,7 +184,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
<SimpleGrid
|
<SimpleGrid
|
||||||
@@ -223,7 +220,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("tracearr")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
<Stack gap="xs" p="xs">
|
<Stack gap="xs" p="xs">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
|
import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "@homarr/integrations/types";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import { WidgetEmptyState } from "../common/empty-state";
|
|||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -212,7 +210,6 @@ function TraefikWidgetContent({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={data} />
|
<IntegrationErrorIndicator results={data} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -265,7 +262,6 @@ function TraefikWidgetContent({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={data} />
|
<IntegrationErrorIndicator results={data} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
@@ -281,7 +277,6 @@ function TraefikWidgetContent({
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={data} />
|
<IntegrationErrorIndicator results={data} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("traefik")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartSurface :global(.mantine-ScrollArea-viewport),
|
.chartSurface :global(.mantine-ScrollArea-viewport),
|
||||||
.chartSurface :global(.mantine-ScrollArea-scrollbar),
|
|
||||||
.chartSurface :global(.recharts-wrapper),
|
.chartSurface :global(.recharts-wrapper),
|
||||||
.chartSurface :global(.recharts-surface) {
|
.chartSurface :global(.recharts-surface) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list
|
|||||||
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
|
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
|
||||||
import type { TimeFrame } from "./umami-utils";
|
import type { TimeFrame } from "./umami-utils";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
|
|
||||||
interface UmamiContentProps {
|
interface UmamiContentProps {
|
||||||
integrationIds: string[];
|
integrationIds: string[];
|
||||||
@@ -270,14 +270,6 @@ export function UmamiContent({
|
|||||||
<Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
|
<Box className={classes.chartSurface} mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||||
{displayMode === "advanced" ? advancedContent : selectedView}
|
{displayMode === "advanced" ? advancedContent : selectedView}
|
||||||
</Box>
|
</Box>
|
||||||
{(statsQuery.error || activeVisitorsQuery.error || multiEventQuery.error) && (
|
|
||||||
<Box pos="absolute" top={4} right={4}>
|
|
||||||
<WidgetQueryErrorIndicator
|
|
||||||
error={statsQuery.error ?? activeVisitorsQuery.error ?? multiEventQuery.error}
|
|
||||||
label={t("name")}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,6 @@ import { WidgetEmptyState } from "../common/empty-state";
|
|||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
|
||||||
|
|
||||||
const statusColors: Record<UpsStatus, string> = {
|
const statusColors: Record<UpsStatus, string> = {
|
||||||
@@ -87,7 +86,6 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={data} />
|
<IntegrationErrorIndicator results={data} />
|
||||||
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<WidgetEmptyState />
|
<WidgetEmptyState />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -118,7 +116,6 @@ function UpsContent({ integrationIds, options, width, height, displayMode }: Ups
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={data} />
|
<IntegrationErrorIndicator results={data} />
|
||||||
<WidgetQueryErrorIndicator error={summariesQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
{displayMode === "advanced" ? (
|
{displayMode === "advanced" ? (
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { formatNumber } from "@homarr/common";
|
import { formatNumber } from "@homarr/common";
|
||||||
import { getIntegrationName } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
import { aggregateUptimeKumaDashboards } from "./aggregate";
|
import { aggregateUptimeKumaDashboards } from "./aggregate";
|
||||||
@@ -254,7 +252,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
{summaryContent}
|
{summaryContent}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -291,7 +288,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h="100%">
|
<ScrollArea h="100%">
|
||||||
<Stack gap="lg" p="md">
|
<Stack gap="lg" p="md">
|
||||||
@@ -307,7 +303,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
|||||||
<Box h="100%" pos="relative">
|
<Box h="100%" pos="relative">
|
||||||
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
|
||||||
<IntegrationErrorIndicator results={dashboardData} />
|
<IntegrationErrorIndicator results={dashboardData} />
|
||||||
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={getIntegrationName("uptimeKuma")} />
|
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||||
{summaryContent}
|
{summaryContent}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function VpnWidget({ options, integrationIds, width, height }: Wi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" offsetScrollbars p="xs">
|
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p="xs">
|
||||||
<SimpleGrid cols={width >= 640 ? 2 : 1} spacing="sm">
|
<SimpleGrid cols={width >= 640 ? 2 : 1} spacing="sm">
|
||||||
{integrations.map((result) => (
|
{integrations.map((result) => (
|
||||||
<Stack key={result.integration.id} gap={4}>
|
<Stack key={result.integration.id} gap={4}>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWea
|
|||||||
const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt);
|
const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
<ScrollArea h="100%" w="100%">
|
||||||
<Stack className={classes.advancedRoot} gap="md" p="md">
|
<Stack className={classes.advancedRoot} gap="md" p="md">
|
||||||
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
|
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
|
||||||
<Group gap="md" wrap="nowrap">
|
<Group gap="md" wrap="nowrap">
|
||||||
|
|||||||
@@ -110,10 +110,6 @@
|
|||||||
outline-offset: 0.125rem;
|
outline-offset: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorIndicator {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (any-pointer: coarse) {
|
@media (any-pointer: coarse) {
|
||||||
.detailsButton,
|
.detailsButton,
|
||||||
.forecastButton,
|
.forecastButton,
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { AdvancedWeather } from "./advanced";
|
import { AdvancedWeather } from "./advanced";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
@@ -20,7 +19,6 @@ export default function WeatherWidget({
|
|||||||
height,
|
height,
|
||||||
displayMode,
|
displayMode,
|
||||||
}: WidgetComponentProps<"weather">) {
|
}: WidgetComponentProps<"weather">) {
|
||||||
const t = useI18n("widget.weather");
|
|
||||||
const input = {
|
const input = {
|
||||||
latitude: options.location.latitude,
|
latitude: options.location.latitude,
|
||||||
longitude: options.location.longitude,
|
longitude: options.location.longitude,
|
||||||
@@ -40,11 +38,6 @@ export default function WeatherWidget({
|
|||||||
<CompactWeather height={height} isEditMode={isEditMode} options={options} weather={weather} width={width} />
|
<CompactWeather height={height} isEditMode={isEditMode} options={options} weather={weather} width={width} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{weatherQuery.error && (
|
|
||||||
<Box pos="absolute" top={4} right={4} className={classes.errorIndicator}>
|
|
||||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ const WudWidgetContent = ({
|
|||||||
isAdvanced: boolean;
|
isAdvanced: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n("widget.wud");
|
const t = useI18n("widget.wud");
|
||||||
const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
|
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
const isTiny = !isAdvanced && width < 256;
|
const isTiny = !isAdvanced && width < 256;
|
||||||
@@ -115,11 +114,6 @@ const WudWidgetContent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="xs" gap="xs" h="100%">
|
<Stack p="xs" gap="xs" h="100%">
|
||||||
{Boolean(statsQuery.error) && (
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{showTitle && !isTiny && (
|
{showTitle && !isTiny && (
|
||||||
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
|
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
|
||||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||||
|
|||||||
Generated
+25
@@ -201,6 +201,9 @@ catalogs:
|
|||||||
'@tanstack/react-query-next-experimental':
|
'@tanstack/react-query-next-experimental':
|
||||||
specifier: ^5.101.2
|
specifier: ^5.101.2
|
||||||
version: 5.101.2
|
version: 5.101.2
|
||||||
|
'@tanstack/react-query-persist-client':
|
||||||
|
specifier: ^5.101.2
|
||||||
|
version: 5.101.2
|
||||||
'@testcontainers/mysql':
|
'@testcontainers/mysql':
|
||||||
specifier: ^12.0.4
|
specifier: ^12.0.4
|
||||||
version: 12.0.4
|
version: 12.0.4
|
||||||
@@ -1142,6 +1145,9 @@ importers:
|
|||||||
'@tanstack/react-query-next-experimental':
|
'@tanstack/react-query-next-experimental':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
|
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
|
||||||
|
'@tanstack/react-query-persist-client':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
|
||||||
'@trpc/client':
|
'@trpc/client':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)
|
version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2)
|
||||||
@@ -8831,6 +8837,9 @@ packages:
|
|||||||
'@tanstack/query-devtools@5.101.2':
|
'@tanstack/query-devtools@5.101.2':
|
||||||
resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
|
resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.101.2':
|
||||||
|
resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==}
|
||||||
|
|
||||||
'@tanstack/react-devtools@0.10.9':
|
'@tanstack/react-devtools@0.10.9':
|
||||||
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
|
resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -8853,6 +8862,12 @@ packages:
|
|||||||
next: ^13 || ^14 || ^15 || ^16
|
next: ^13 || ^14 || ^15 || ^16
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
'@tanstack/react-query-persist-client@5.101.2':
|
||||||
|
resolution: {integrity: sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tanstack/react-query': ^5.101.2
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tanstack/react-query@5.101.2':
|
'@tanstack/react-query@5.101.2':
|
||||||
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
|
resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -23923,6 +23938,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/query-devtools@5.101.2': {}
|
'@tanstack/query-devtools@5.101.2': {}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.101.2':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.101.2
|
||||||
|
|
||||||
'@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)':
|
'@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
|
'@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14)
|
||||||
@@ -23948,6 +23967,12 @@ snapshots:
|
|||||||
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|
||||||
|
'@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-persist-client-core': 5.101.2
|
||||||
|
'@tanstack/react-query': 5.101.2(react@19.2.7)
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
'@tanstack/react-query@5.101.2(react@19.2.7)':
|
'@tanstack/react-query@5.101.2(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.101.2
|
'@tanstack/query-core': 5.101.2
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ catalog:
|
|||||||
"@tanstack/react-query": ^5.101.2
|
"@tanstack/react-query": ^5.101.2
|
||||||
"@tanstack/react-query-devtools": ^5.101.2
|
"@tanstack/react-query-devtools": ^5.101.2
|
||||||
"@tanstack/react-query-next-experimental": ^5.101.2
|
"@tanstack/react-query-next-experimental": ^5.101.2
|
||||||
|
"@tanstack/react-query-persist-client": ^5.101.2
|
||||||
"@testcontainers/mysql": ^12.0.4
|
"@testcontainers/mysql": ^12.0.4
|
||||||
"@testcontainers/postgresql": ^12.0.4
|
"@testcontainers/postgresql": ^12.0.4
|
||||||
"@testcontainers/redis": ^12.0.4
|
"@testcontainers/redis": ^12.0.4
|
||||||
|
|||||||
Reference in new issue
Block a user