From fda1c851cf507ca7e309e80ff068444dfaab93c3 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 12 Oct 2025 13:42:24 +0000 Subject: feat: Add service dependency checks in the server overview page --- apps/web/app/admin/overview/page.tsx | 11 +- apps/web/components/admin/BasicStats.tsx | 112 +++++++++++++++++ apps/web/components/admin/ServerStats.tsx | 97 --------------- apps/web/components/admin/ServiceConnections.tsx | 150 +++++++++++++++++++++++ apps/web/lib/i18n/locales/en/translation.json | 12 ++ packages/shared/plugins.ts | 8 ++ packages/trpc/routers/admin.ts | 116 ++++++++++++++++++ 7 files changed, 404 insertions(+), 102 deletions(-) create mode 100644 apps/web/components/admin/BasicStats.tsx delete mode 100644 apps/web/components/admin/ServerStats.tsx create mode 100644 apps/web/components/admin/ServiceConnections.tsx diff --git a/apps/web/app/admin/overview/page.tsx b/apps/web/app/admin/overview/page.tsx index fe463058..66844d04 100644 --- a/apps/web/app/admin/overview/page.tsx +++ b/apps/web/app/admin/overview/page.tsx @@ -1,10 +1,11 @@ -import { AdminCard } from "@/components/admin/AdminCard"; -import ServerStats from "@/components/admin/ServerStats"; +import BasicStats from "@/components/admin/BasicStats"; +import ServiceConnections from "@/components/admin/ServiceConnections"; export default function AdminOverviewPage() { return ( - - - +
+ + +
); } diff --git a/apps/web/components/admin/BasicStats.tsx b/apps/web/components/admin/BasicStats.tsx new file mode 100644 index 00000000..67352f66 --- /dev/null +++ b/apps/web/components/admin/BasicStats.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { AdminCard } from "@/components/admin/AdminCard"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +const REPO_LATEST_RELEASE_API = + "https://api.github.com/repos/karakeep-app/karakeep/releases/latest"; +const REPO_RELEASE_PAGE = "https://github.com/karakeep-app/karakeep/releases"; + +function useLatestRelease() { + const { data } = useQuery({ + queryKey: ["latest-release"], + queryFn: async () => { + const res = await fetch(REPO_LATEST_RELEASE_API); + if (!res.ok) { + return undefined; + } + const data = (await res.json()) as { name: string }; + return data.name; + }, + staleTime: 60 * 60 * 1000, + enabled: !useClientConfig().disableNewReleaseCheck, + }); + return data; +} + +function ReleaseInfo() { + const currentRelease = useClientConfig().serverVersion ?? "NA"; + const latestRelease = useLatestRelease(); + + let newRelease; + if (latestRelease && currentRelease != latestRelease) { + newRelease = ( + // oxlint-disable-next-line no-html-link-for-pages + + ({latestRelease} ⬆️) + + ); + } + return ( +
+ {currentRelease} + {newRelease} +
+ ); +} + +function StatsSkeleton() { + return ( + +
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+
+ ); +} + +export default function BasicStats() { + const { t } = useTranslation(); + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 5000, + }); + + if (!serverStats) { + return ; + } + + return ( + +
+ {t("admin.server_stats.server_stats")} +
+
+
+
+ {t("admin.server_stats.total_users")} +
+
{serverStats.numUsers}
+
+
+
+ {t("admin.server_stats.total_bookmarks")} +
+
+ {serverStats.numBookmarks} +
+
+
+
+ {t("admin.server_stats.server_version")} +
+ +
+
+
+ ); +} diff --git a/apps/web/components/admin/ServerStats.tsx b/apps/web/components/admin/ServerStats.tsx deleted file mode 100644 index e60f0fbe..00000000 --- a/apps/web/components/admin/ServerStats.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import LoadingSpinner from "@/components/ui/spinner"; -import { useClientConfig } from "@/lib/clientConfig"; -import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; - -const REPO_LATEST_RELEASE_API = - "https://api.github.com/repos/karakeep-app/karakeep/releases/latest"; -const REPO_RELEASE_PAGE = "https://github.com/karakeep-app/karakeep/releases"; - -function useLatestRelease() { - const { data } = useQuery({ - queryKey: ["latest-release"], - queryFn: async () => { - const res = await fetch(REPO_LATEST_RELEASE_API); - if (!res.ok) { - return undefined; - } - const data = (await res.json()) as { name: string }; - return data.name; - }, - staleTime: 60 * 60 * 1000, - enabled: !useClientConfig().disableNewReleaseCheck, - }); - return data; -} - -function ReleaseInfo() { - const currentRelease = useClientConfig().serverVersion ?? "NA"; - const latestRelease = useLatestRelease(); - - let newRelease; - if (latestRelease && currentRelease != latestRelease) { - newRelease = ( - // oxlint-disable-next-line no-html-link-for-pages - - ({latestRelease} ⬆️) - - ); - } - return ( -
- {currentRelease} - {newRelease} -
- ); -} - -export default function ServerStats() { - const { t } = useTranslation(); - const { data: serverStats } = api.admin.stats.useQuery(undefined, { - refetchInterval: 5000, - placeholderData: keepPreviousData, - }); - - if (!serverStats) { - return ; - } - - return ( -
-
- {t("admin.server_stats.server_stats")} -
-
-
-
- {t("admin.server_stats.total_users")} -
-
{serverStats.numUsers}
-
-
-
- {t("admin.server_stats.total_bookmarks")} -
-
- {serverStats.numBookmarks} -
-
-
-
- {t("admin.server_stats.server_version")} -
- -
-
-
- ); -} diff --git a/apps/web/components/admin/ServiceConnections.tsx b/apps/web/components/admin/ServiceConnections.tsx new file mode 100644 index 00000000..8d79d8bb --- /dev/null +++ b/apps/web/components/admin/ServiceConnections.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { AdminCard } from "@/components/admin/AdminCard"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; + +function ConnectionStatus({ + label, + configured, + connected, + pluginName, + error, +}: { + label: string; + configured: boolean; + connected: boolean; + pluginName?: string; + error?: string; +}) { + const { t } = useTranslation(); + + let statusText = t("admin.service_connections.status.not_configured"); + let badgeColor = + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"; + let iconColor = "text-gray-400"; + let borderColor = "border-gray-200 dark:border-gray-700"; + + if (configured) { + if (connected) { + statusText = t("admin.service_connections.status.connected"); + badgeColor = + "bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"; + iconColor = "text-green-500"; + borderColor = "border-green-200 dark:border-green-800"; + } else { + statusText = t("admin.service_connections.status.disconnected"); + badgeColor = + "bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400"; + iconColor = "text-red-500"; + borderColor = "border-red-200 dark:border-red-800"; + } + } + + return ( +
+
+
+
{label}
+ {pluginName && ( +
+ {pluginName} +
+ )} +
+
+
+
+
+
+ + {statusText} + +
+ {error && ( +
+

+ {error.length > 60 ? `${error.substring(0, 60)}...` : error} +

+
+ )} +
+ ); +} + +function ConnectionsSkeleton() { + return ( + +
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} + +export default function ServiceConnections() { + const { t } = useTranslation(); + const { data: connections } = api.admin.checkConnections.useQuery(undefined, { + refetchInterval: 10000, + }); + + if (!connections) { + return ; + } + + return ( + +
+ {t("admin.service_connections.title")} +
+

+ {t("admin.service_connections.description")} +

+
+ + + +
+
+ ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 450949c6..4e79be51 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -351,6 +351,18 @@ "total_bookmarks": "Total Bookmarks", "server_version": "Server Version" }, + "service_connections": { + "title": "Service Connections", + "description": "Monitor the health and connectivity of external system dependencies", + "search_engine": "Search Engine", + "browser": "Browser", + "queue_system": "Queue System", + "status": { + "not_configured": "Not Configured", + "connected": "Connected", + "disconnected": "Disconnected" + } + }, "background_jobs": { "jobs": { "crawler": { diff --git a/packages/shared/plugins.ts b/packages/shared/plugins.ts index 2aa7df4a..e04fd91e 100644 --- a/packages/shared/plugins.ts +++ b/packages/shared/plugins.ts @@ -51,6 +51,14 @@ export class PluginManager { return PluginManager.providers[type].length > 0; } + static getPluginName(type: T): string | null { + const providers: TPlugin[] = PluginManager.providers[type]; + if (providers.length === 0) { + return null; + } + return providers[providers.length - 1]!.name; + } + static logAllPlugins() { logger.info("Plugins (Last one wins):"); for (const type of Object.values(PluginType)) { diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts index 25425eaf..a40dfa6f 100644 --- a/packages/trpc/routers/admin.ts +++ b/packages/trpc/routers/admin.ts @@ -14,6 +14,8 @@ import { VideoWorkerQueue, WebhookQueue, } from "@karakeep/shared-server"; +import serverConfig from "@karakeep/shared/config"; +import { PluginManager, PluginType } from "@karakeep/shared/plugins"; import { getSearchClient } from "@karakeep/shared/search"; import { resetPasswordSchema, @@ -417,4 +419,118 @@ export const adminAppRouter = router({ // Unused for now }; }), + checkConnections: adminProcedure + .output( + z.object({ + searchEngine: z.object({ + configured: z.boolean(), + connected: z.boolean(), + pluginName: z.string().optional(), + error: z.string().optional(), + }), + browser: z.object({ + configured: z.boolean(), + connected: z.boolean(), + pluginName: z.string().optional(), + error: z.string().optional(), + }), + queue: z.object({ + configured: z.boolean(), + connected: z.boolean(), + pluginName: z.string().optional(), + error: z.string().optional(), + }), + }), + ) + .query(async () => { + const searchEngineStatus: { + configured: boolean; + connected: boolean; + pluginName?: string; + error?: string; + } = { configured: false, connected: false }; + const browserStatus: { + configured: boolean; + connected: boolean; + pluginName?: string; + error?: string; + } = { configured: false, connected: false }; + const queueStatus: { + configured: boolean; + connected: boolean; + pluginName?: string; + error?: string; + } = { configured: true, connected: false }; + + const searchClient = await getSearchClient(); + searchEngineStatus.configured = searchClient !== null; + + if (searchClient) { + const pluginName = PluginManager.getPluginName(PluginType.Search); + if (pluginName) { + searchEngineStatus.pluginName = pluginName; + } + try { + await searchClient.search({ query: "", limit: 1 }); + searchEngineStatus.connected = true; + } catch (error) { + searchEngineStatus.error = + error instanceof Error ? error.message : "Unknown error"; + } + } + + browserStatus.configured = + !!serverConfig.crawler.browserWebUrl || + !!serverConfig.crawler.browserWebSocketUrl; + + if (browserStatus.configured) { + if (serverConfig.crawler.browserWebUrl) { + browserStatus.pluginName = "Browserless/Chrome"; + } else if (serverConfig.crawler.browserWebSocketUrl) { + browserStatus.pluginName = "WebSocket Browser"; + } + + try { + if (serverConfig.crawler.browserWebUrl) { + const response = await fetch( + `${serverConfig.crawler.browserWebUrl}/json/version`, + { + signal: AbortSignal.timeout(5000), + }, + ); + if (response.ok) { + browserStatus.connected = true; + } else { + browserStatus.error = `HTTP ${response.status}: ${response.statusText}`; + } + } else if (serverConfig.crawler.browserWebSocketUrl) { + browserStatus.connected = true; + browserStatus.error = + "WebSocket URL configured (connection check not supported)"; + } + } catch (error) { + browserStatus.error = + error instanceof Error ? error.message : "Unknown error"; + } + } + + const queuePluginName = PluginManager.getPluginName(PluginType.Queue); + if (queuePluginName) { + queueStatus.pluginName = queuePluginName; + } + + try { + await LinkCrawlerQueue.stats(); + queueStatus.connected = true; + } catch (error) { + queueStatus.error = + error instanceof Error ? error.message : "Unknown error"; + } + + return { + searchEngine: searchEngineStatus, + browser: browserStatus, + queue: queueStatus, + }; + }), }); -- cgit v1.2.3-70-g09d2