aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/admin/overview/page.tsx11
-rw-r--r--apps/web/components/admin/BasicStats.tsx (renamed from apps/web/components/admin/ServerStats.tsx)31
-rw-r--r--apps/web/components/admin/ServiceConnections.tsx150
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json12
-rw-r--r--packages/shared/plugins.ts8
-rw-r--r--packages/trpc/routers/admin.ts116
6 files changed, 315 insertions, 13 deletions
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 (
- <AdminCard>
- <ServerStats />
- </AdminCard>
+ <div className="flex flex-col gap-6">
+ <BasicStats />
+ <ServiceConnections />
+ </div>
);
}
diff --git a/apps/web/components/admin/ServerStats.tsx b/apps/web/components/admin/BasicStats.tsx
index e60f0fbe..67352f66 100644
--- a/apps/web/components/admin/ServerStats.tsx
+++ b/apps/web/components/admin/BasicStats.tsx
@@ -1,10 +1,10 @@
"use client";
-import LoadingSpinner from "@/components/ui/spinner";
+import { AdminCard } from "@/components/admin/AdminCard";
import { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
-import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
const REPO_LATEST_RELEASE_API =
"https://api.github.com/repos/karakeep-app/karakeep/releases/latest";
@@ -54,20 +54,35 @@ function ReleaseInfo() {
);
}
-export default function ServerStats() {
+function StatsSkeleton() {
+ return (
+ <AdminCard>
+ <div className="mb-4 h-7 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
+ <div className="flex flex-col gap-4 sm:flex-row">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="rounded-md border bg-background p-4 sm:w-1/4">
+ <div className="mb-2 h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
+ <div className="h-9 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
+ </div>
+ ))}
+ </div>
+ </AdminCard>
+ );
+}
+
+export default function BasicStats() {
const { t } = useTranslation();
const { data: serverStats } = api.admin.stats.useQuery(undefined, {
refetchInterval: 5000,
- placeholderData: keepPreviousData,
});
if (!serverStats) {
- return <LoadingSpinner />;
+ return <StatsSkeleton />;
}
return (
- <div className="flex flex-col gap-4">
- <div className="mb-2 text-xl font-medium">
+ <AdminCard>
+ <div className="mb-4 text-xl font-medium">
{t("admin.server_stats.server_stats")}
</div>
<div className="flex flex-col gap-4 sm:flex-row">
@@ -92,6 +107,6 @@ export default function ServerStats() {
<ReleaseInfo />
</div>
</div>
- </div>
+ </AdminCard>
);
}
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 (
+ <div
+ className={`rounded-lg border ${borderColor} bg-background p-5 shadow-sm transition-all sm:w-1/3`}
+ >
+ <div className="mb-3 flex items-center justify-between">
+ <div>
+ <div className="text-base font-semibold">{label}</div>
+ {pluginName && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ {pluginName}
+ </div>
+ )}
+ </div>
+ <div
+ className={`flex h-2 w-2 items-center justify-center rounded-full ${iconColor}`}
+ >
+ <div
+ className={`h-2 w-2 rounded-full ${connected && configured ? "animate-pulse" : ""} bg-current`}
+ ></div>
+ </div>
+ </div>
+ <div className="mb-2">
+ <span
+ className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${badgeColor}`}
+ >
+ {statusText}
+ </span>
+ </div>
+ {error && (
+ <div className="mt-3 rounded-md bg-red-50 p-2 dark:bg-red-900/10">
+ <p className="text-xs text-red-600 dark:text-red-400" title={error}>
+ {error.length > 60 ? `${error.substring(0, 60)}...` : error}
+ </p>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function ConnectionsSkeleton() {
+ return (
+ <AdminCard>
+ <div className="mb-4 h-7 w-40 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
+ <div className="flex flex-col gap-4 sm:flex-row">
+ {[1, 2, 3].map((i) => (
+ <div
+ key={i}
+ className="rounded-lg border border-gray-200 bg-background p-5 shadow-sm dark:border-gray-700 sm:w-1/3"
+ >
+ <div className="mb-3 flex items-center justify-between">
+ <div className="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
+ <div className="h-2 w-2 animate-pulse rounded-full bg-gray-300 dark:bg-gray-600"></div>
+ </div>
+ <div className="mb-2">
+ <div className="h-5 w-20 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </AdminCard>
+ );
+}
+
+export default function ServiceConnections() {
+ const { t } = useTranslation();
+ const { data: connections } = api.admin.checkConnections.useQuery(undefined, {
+ refetchInterval: 10000,
+ });
+
+ if (!connections) {
+ return <ConnectionsSkeleton />;
+ }
+
+ return (
+ <AdminCard>
+ <div className="mb-2 text-xl font-medium">
+ {t("admin.service_connections.title")}
+ </div>
+ <p className="mb-4 text-sm text-muted-foreground">
+ {t("admin.service_connections.description")}
+ </p>
+ <div className="flex flex-col gap-4 sm:flex-row">
+ <ConnectionStatus
+ label={t("admin.service_connections.search_engine")}
+ configured={connections.searchEngine.configured}
+ connected={connections.searchEngine.connected}
+ pluginName={connections.searchEngine.pluginName}
+ error={connections.searchEngine.error}
+ />
+ <ConnectionStatus
+ label={t("admin.service_connections.browser")}
+ configured={connections.browser.configured}
+ connected={connections.browser.connected}
+ pluginName={connections.browser.pluginName}
+ error={connections.browser.error}
+ />
+ <ConnectionStatus
+ label={t("admin.service_connections.queue_system")}
+ configured={connections.queue.configured}
+ connected={connections.queue.connected}
+ pluginName={connections.queue.pluginName}
+ error={connections.queue.error}
+ />
+ </div>
+ </AdminCard>
+ );
+}
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<T extends PluginType>(type: T): string | null {
+ const providers: TPlugin<T>[] = 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,
+ };
+ }),
});