From fec66e4de85bfa96bc39b4deead4ceb7dc5cf020 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Wed, 20 May 2026 20:38:19 +0300 Subject: [PATCH] feat(webui): expose shell status in root context - add a shared shell client and root /status query - attach shell status to the TanStack root context for React routes - keep the shell bridge types and test setup aligned with the new status data --- webui/src/app/api-client.ts | 9 +++++++ webui/src/platform/shell/bridge.ts | 6 ++++- .../src/platform/shell/route-controllers.tsx | 4 +++ webui/src/platform/shell/status.test.ts | 19 ++++++++++++++ webui/src/platform/shell/status.ts | 25 +++++++++++++++++++ webui/src/routes/__root.tsx | 11 +++++--- webui/vitest.setup.ts | 12 +++++++-- 7 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 webui/src/platform/shell/status.test.ts create mode 100644 webui/src/platform/shell/status.ts diff --git a/webui/src/app/api-client.ts b/webui/src/app/api-client.ts index fa54158e..73541181 100644 --- a/webui/src/app/api-client.ts +++ b/webui/src/app/api-client.ts @@ -6,12 +6,21 @@ const apiBaseUrl = typeof globalThis.location === 'object' ? new URL('/api/', globalThis.location.origin).toString() : 'http://localhost/api/'; +const shellBaseUrl = + typeof globalThis.location === 'object' + ? new URL('/', globalThis.location.origin).toString() + : 'http://localhost/'; export const apiClient = ky.create({ baseUrl: apiBaseUrl, retry: 0, }); +export const shellClient = ky.create({ + baseUrl: shellBaseUrl, + retry: 0, +}); + export async function readJson(promise: ResponsePromise): Promise { try { return await promise.json(); diff --git a/webui/src/platform/shell/bridge.ts b/webui/src/platform/shell/bridge.ts index 1c9683f5..cd56f64a 100644 --- a/webui/src/platform/shell/bridge.ts +++ b/webui/src/platform/shell/bridge.ts @@ -1,5 +1,7 @@ import type { AnyRouter } from '@tanstack/react-router'; +import type { ShellStatusPayload } from './status'; + import { getShellRouteByPageId, normalizeShellPath, @@ -17,6 +19,7 @@ export interface ShellProfileContext { export interface ShellContext { bridge: ShellBridge; profile: ShellProfileContext; + status?: ShellStatusPayload | null; } export type ShellBridge = NonNullable; @@ -95,7 +98,8 @@ export function bindWindowWebRouter(router: AnyRouter) { let href: `/${string}` = route.path; if (pageId === 'artist-detail' && options?.artistId) { const source = options.artistSource ? String(options.artistSource) : 'library'; - href = `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`; + href = + `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`; } await router.navigate({ href, replace: options?.replace === true }); diff --git a/webui/src/platform/shell/route-controllers.tsx b/webui/src/platform/shell/route-controllers.tsx index a08d3c66..309ed1d1 100644 --- a/webui/src/platform/shell/route-controllers.tsx +++ b/webui/src/platform/shell/route-controllers.tsx @@ -21,6 +21,10 @@ export function useProfile() { return useShellContext().profile; } +export function useShellStatus() { + return useShellContext().status ?? null; +} + export function LegacyRouteController({ pathname }: { pathname: string }) { const bridge = useShellBridge(); diff --git a/webui/src/platform/shell/status.test.ts b/webui/src/platform/shell/status.test.ts new file mode 100644 index 00000000..68c1bf6f --- /dev/null +++ b/webui/src/platform/shell/status.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpResponse, http, server } from '@/test/msw'; + +import { fetchShellStatus } from './status'; + +describe('shell status', () => { + it('fetches the shell status payload', async () => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + ); + + await expect(fetchShellStatus()).resolves.toEqual({ + media_server: { type: 'plex', connected: true }, + }); + }); +}); diff --git a/webui/src/platform/shell/status.ts b/webui/src/platform/shell/status.ts new file mode 100644 index 00000000..47b53321 --- /dev/null +++ b/webui/src/platform/shell/status.ts @@ -0,0 +1,25 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { readJson, shellClient } from '@/app/api-client'; + +export interface ShellStatusMediaServer { + type?: string | null; + connected?: boolean | null; +} + +export interface ShellStatusPayload { + media_server?: ShellStatusMediaServer | null; +} + +export async function fetchShellStatus(): Promise { + return await readJson(shellClient.get('status')); +} + +export function shellStatusQueryOptions() { + return queryOptions({ + queryKey: ['shell', 'status'] as const, + queryFn: fetchShellStatus, + staleTime: 30_000, + retry: false, + }); +} diff --git a/webui/src/routes/__root.tsx b/webui/src/routes/__root.tsx index d46454e1..44575f1c 100644 --- a/webui/src/routes/__root.tsx +++ b/webui/src/routes/__root.tsx @@ -3,13 +3,18 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; import type { AppRouterContext } from '@/app/router'; import { waitForShellContext } from '@/platform/shell/bridge'; +import { shellStatusQueryOptions } from '@/platform/shell/status'; import { IssueDomainHost } from './issues/-ui/issue-domain-host'; export const Route = createRootRouteWithContext()({ - beforeLoad: async () => { - const shell = await waitForShellContext(); - return { shell }; + beforeLoad: async ({ context }) => { + const [shell, status] = await Promise.all([ + waitForShellContext(), + context.queryClient.fetchQuery(shellStatusQueryOptions()).catch(() => undefined), + ]); + + return { shell: { ...shell, status } }; }, component: () => ( <> diff --git a/webui/vitest.setup.ts b/webui/vitest.setup.ts index 2b86553f..c99c9e8d 100644 --- a/webui/vitest.setup.ts +++ b/webui/vitest.setup.ts @@ -1,12 +1,20 @@ import '@testing-library/jest-dom/vitest'; -import { afterAll, afterEach, beforeAll, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest'; -import { server } from './src/test/msw'; +import { HttpResponse, http, server } from './src/test/msw'; beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }); }); +beforeEach(() => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + ); +}); + afterEach(() => { server.resetHandlers(); });