aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/admin/BackgroundJobs.tsx
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2025-08-21 18:24:03 +0300
committerMohamedBassem <me@mbassem.com>2025-08-21 18:24:41 +0300
commite2c303acb087cab2e1ed74b08796693e2108bc7e (patch)
treef4bcd7dc4e70f02099b740f0ba15912d02b892e9 /apps/web/components/admin/BackgroundJobs.tsx
parentff33b310e2199dbddf929715530eadc3162266eb (diff)
downloadkarakeep-e2c303acb087cab2e1ed74b08796693e2108bc7e.tar.zst
feat: A redesigned background jobs page. #1551
Diffstat (limited to 'apps/web/components/admin/BackgroundJobs.tsx')
-rw-r--r--apps/web/components/admin/BackgroundJobs.tsx590
1 files changed, 420 insertions, 170 deletions
diff --git a/apps/web/components/admin/BackgroundJobs.tsx b/apps/web/components/admin/BackgroundJobs.tsx
index ac5885ef..56d3531f 100644
--- a/apps/web/components/admin/BackgroundJobs.tsx
+++ b/apps/web/components/admin/BackgroundJobs.tsx
@@ -1,23 +1,249 @@
"use client";
import { ActionButton } from "@/components/ui/action-button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";
-
-import LoadingSpinner from "../ui/spinner";
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "../ui/table";
+ Activity,
+ AlertTriangle,
+ Clock,
+ Database,
+ Globe,
+ HelpCircle,
+ Image,
+ RefreshCw,
+ Rss,
+ Search,
+ Sparkle,
+ Video,
+ Webhook,
+} from "lucide-react";
+
+import { AdminCard } from "./AdminCard";
+
+interface JobStats {
+ queued: number;
+ pending?: number;
+ failed?: number;
+}
+
+interface JobAction {
+ label: string;
+ onClick: () => void;
+ loading: boolean;
+}
+
+function JobStatusExplanation() {
+ const { t } = useTranslation();
+
+ return (
+ <Card className="border-blue-200 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-900/10">
+ <CardHeader className="pb-4">
+ <CardTitle className="flex items-center gap-2 text-lg text-blue-900 dark:text-blue-200">
+ <HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
+ {t("admin.background_jobs.status.title")}
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid gap-4 md:grid-cols-3">
+ <div className="flex items-start gap-3">
+ <div className="flex h-8 w-8 items-center justify-center">
+ <Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
+ </div>
+ <div>
+ <h4 className="font-medium text-blue-900 dark:text-blue-200">
+ {t("admin.background_jobs.status.queued.title")}
+ </h4>
+ <p className="text-sm text-blue-700 dark:text-blue-300">
+ {t("admin.background_jobs.status.queued.description")}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-start gap-3">
+ <div className="flex h-8 w-8 items-center justify-center">
+ <RefreshCw className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
+ </div>
+ <div>
+ <h4 className="font-medium text-yellow-900 dark:text-yellow-400">
+ {t("admin.background_jobs.status.unprocessed.title")}
+ </h4>
+ <p className="text-sm text-yellow-700 dark:text-yellow-500">
+ {t("admin.background_jobs.status.unprocessed.description")}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-start gap-3">
+ <div className="flex h-8 w-8 items-center justify-center">
+ <AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-500" />
+ </div>
+ <div>
+ <h4 className="font-medium text-red-900 dark:text-red-500">
+ {t("admin.background_jobs.status.failed.title")}
+ </h4>
+ <p className="text-sm text-red-700 dark:text-red-500">
+ {t("admin.background_jobs.status.failed.description")}
+ </p>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
+
+function JobCardSkeleton() {
+ return (
+ <Card className="relative overflow-hidden">
+ <CardHeader className="pb-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <Skeleton className="h-5 w-5" />
+ <Skeleton className="h-6 w-32" />
+ </div>
+ <Skeleton className="h-6 w-16" />
+ </div>
+ <Skeleton className="mt-2 h-4 w-3/4" />
+ </CardHeader>
+ <CardContent className="space-y-5">
+ <div className="flex flex-wrap items-center gap-4">
+ <div className="flex items-center gap-2">
+ <Skeleton className="h-4 w-4" />
+ <Skeleton className="h-4 w-16" />
+ <Skeleton className="h-6 w-8" />
+ </div>
+ <div className="flex items-center gap-2">
+ <Skeleton className="h-4 w-4" />
+ <Skeleton className="h-4 w-20" />
+ <Skeleton className="h-6 w-8" />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex justify-between">
+ <Skeleton className="h-3 w-20" />
+ <Skeleton className="h-3 w-16" />
+ </div>
+ <Skeleton className="h-2 w-full" />
+ </div>
-function AdminActions() {
+ <div className="space-y-3 border-t pt-4">
+ <Skeleton className="h-4 w-32" />
+ <div className="grid gap-2">
+ <Skeleton className="h-9 w-full" />
+ <Skeleton className="h-9 w-full" />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
+
+function JobCard({
+ title,
+ icon: Icon,
+ stats,
+ description,
+ actions = [],
+}: {
+ title: string;
+ icon: React.ComponentType<{ className?: string }>;
+ stats: JobStats;
+ description: string;
+ actions?: JobAction[];
+}) {
+ const { t } = useTranslation();
+ const total = stats.queued + (stats.pending || 0) + (stats.failed || 0);
+ const hasActivity = total > 0;
+
+ return (
+ <Card className="relative overflow-hidden">
+ <CardHeader className="pb-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <Icon
+ className={`h-5 w-5 ${hasActivity ? "text-primary" : "text-muted-foreground"}`}
+ />
+ <CardTitle className="text-lg">{title}</CardTitle>
+ </div>
+ {hasActivity && (
+ <Badge variant="secondary">
+ <Activity className="mr-1 h-3 w-3" />
+ {t("admin.background_jobs.active")}
+ </Badge>
+ )}
+ </div>
+ <CardDescription className="mt-2 text-sm">
+ {description}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-5">
+ <div className="flex flex-wrap items-center gap-4 text-sm">
+ <div className="flex items-center gap-2">
+ <Clock className="h-4 w-4 text-blue-500" />
+ <span className="font-medium">
+ {t("admin.background_jobs.status.queued.title")}
+ </span>
+ <Badge variant="outline">{stats.queued}</Badge>
+ </div>
+ {stats.pending !== undefined && (
+ <div className="flex items-center gap-2">
+ <RefreshCw className="h-4 w-4 text-yellow-500" />
+ <span className="font-medium">
+ {t("admin.background_jobs.status.unprocessed.title")}
+ </span>
+ <Badge variant="outline">{stats.pending}</Badge>
+ </div>
+ )}
+ {stats.failed !== undefined && stats.failed > 0 && (
+ <div className="flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <span className="font-medium">
+ {t("admin.background_jobs.status.failed.title")}
+ </span>
+ <Badge variant="outline">{stats.failed}</Badge>
+ </div>
+ )}
+ </div>
+
+ {actions.length > 0 && (
+ <div className="space-y-3 border-t pt-4">
+ <h4 className="text-sm font-medium text-muted-foreground">
+ {t("admin.background_jobs.available_actions")}
+ </h4>
+ <div className="grid gap-2">
+ {actions.map((action, index) => (
+ <ActionButton
+ key={index}
+ variant="secondary"
+ loading={action.loading}
+ onClick={action.onClick}
+ className="h-auto justify-start px-3 py-2.5 text-left text-sm"
+ >
+ {action.label}
+ </ActionButton>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
+
+function useJobActions() {
const { t } = useTranslation();
+
const { mutate: recrawlLinks, isPending: isRecrawlPending } =
api.admin.recrawlLinks.useMutation({
onSuccess: () => {
@@ -95,93 +321,88 @@ function AdminActions() {
},
});
- return (
- <div className="flex flex-col gap-2 sm:w-1/2">
- <ActionButton
- variant="destructive"
- loading={isRecrawlPending}
- onClick={() =>
- recrawlLinks({ crawlStatus: "failure", runInference: true })
- }
- >
- {t("admin.actions.recrawl_failed_links_only")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isRecrawlPending}
- onClick={() => recrawlLinks({ crawlStatus: "all", runInference: true })}
- >
- {t("admin.actions.recrawl_all_links")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isRecrawlPending}
- onClick={() =>
- recrawlLinks({ crawlStatus: "all", runInference: false })
- }
- >
- {t("admin.actions.recrawl_all_links")} (
- {t("admin.actions.without_inference")})
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isInferencePending}
- onClick={() =>
- reRunInferenceOnAllBookmarks({ type: "tag", status: "failure" })
- }
- >
- {t("admin.actions.regenerate_ai_tags_for_failed_bookmarks_only")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isInferencePending}
- onClick={() =>
- reRunInferenceOnAllBookmarks({ type: "tag", status: "all" })
- }
- >
- {t("admin.actions.regenerate_ai_tags_for_all_bookmarks")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isInferencePending}
- onClick={() =>
- reRunInferenceOnAllBookmarks({ type: "summarize", status: "failure" })
- }
- >
- {t("admin.actions.regenerate_ai_summaries_for_failed_bookmarks_only")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isInferencePending}
- onClick={() =>
- reRunInferenceOnAllBookmarks({ type: "summarize", status: "all" })
- }
- >
- {t("admin.actions.regenerate_ai_summaries_for_all_bookmarks")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isReindexPending}
- onClick={() => reindexBookmarks()}
- >
- {t("admin.actions.reindex_all_bookmarks")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isReprocessingPending}
- onClick={() => reprocessAssetsFixMode()}
- >
- {t("admin.actions.reprocess_assets_fix_mode")}
- </ActionButton>
- <ActionButton
- variant="destructive"
- loading={isTidyAssetsPending}
- onClick={() => tidyAssets()}
- >
- {t("admin.actions.compact_assets")}
- </ActionButton>
- </div>
- );
+ return {
+ crawlActions: [
+ {
+ label: t("admin.background_jobs.actions.recrawl_failed_links_only"),
+ onClick: () =>
+ recrawlLinks({ crawlStatus: "failure", runInference: true }),
+ variant: "secondary" as const,
+ loading: isRecrawlPending,
+ },
+ {
+ label: t("admin.background_jobs.actions.recrawl_all_links"),
+ onClick: () => recrawlLinks({ crawlStatus: "all", runInference: true }),
+ loading: isRecrawlPending,
+ },
+ {
+ label: `${t("admin.background_jobs.actions.recrawl_all_links")} (${t("admin.background_jobs.actions.without_inference")})`,
+ onClick: () =>
+ recrawlLinks({ crawlStatus: "all", runInference: false }),
+ loading: isRecrawlPending,
+ },
+ ],
+ inferenceActions: [
+ {
+ label: t(
+ "admin.background_jobs.actions.regenerate_ai_tags_for_failed_bookmarks_only",
+ ),
+ onClick: () =>
+ reRunInferenceOnAllBookmarks({ type: "tag", status: "failure" }),
+ variant: "secondary" as const,
+ loading: isInferencePending,
+ },
+ {
+ label: t(
+ "admin.background_jobs.actions.regenerate_ai_tags_for_all_bookmarks",
+ ),
+ onClick: () =>
+ reRunInferenceOnAllBookmarks({ type: "tag", status: "all" }),
+ loading: isInferencePending,
+ },
+ {
+ label: t(
+ "admin.background_jobs.actions.regenerate_ai_summaries_for_failed_bookmarks_only",
+ ),
+ onClick: () =>
+ reRunInferenceOnAllBookmarks({
+ type: "summarize",
+ status: "failure",
+ }),
+ variant: "secondary" as const,
+ loading: isInferencePending,
+ },
+ {
+ label: t(
+ "admin.background_jobs.actions.regenerate_ai_summaries_for_all_bookmarks",
+ ),
+ onClick: () =>
+ reRunInferenceOnAllBookmarks({ type: "summarize", status: "all" }),
+ loading: isInferencePending,
+ },
+ ],
+ indexingActions: [
+ {
+ label: t("admin.background_jobs.actions.reindex_all_bookmarks"),
+ onClick: () => reindexBookmarks(),
+ loading: isReindexPending,
+ },
+ ],
+ assetPreprocessingActions: [
+ {
+ label: t("admin.background_jobs.actions.reprocess_assets_fix_mode"),
+ onClick: () => reprocessAssetsFixMode(),
+ loading: isReprocessingPending,
+ },
+ ],
+ tidyAssetsActions: [
+ {
+ label: t("admin.background_jobs.actions.clean_assets"),
+ onClick: () => tidyAssets(),
+ loading: isTidyAssetsPending,
+ },
+ ],
+ };
}
export default function BackgroundJobs() {
@@ -194,84 +415,113 @@ export default function BackgroundJobs() {
},
);
+ const actions = useJobActions();
+
if (!serverStats) {
- return <LoadingSpinner />;
+ return (
+ <div className="space-y-6">
+ <div className="space-y-2">
+ <Skeleton className="h-8 w-64" />
+ <Skeleton className="h-5 w-96" />
+ </div>
+
+ <div className="grid gap-6 xl:grid-cols-2">
+ {Array.from({ length: 8 }).map((_, index) => (
+ <JobCardSkeleton key={index} />
+ ))}
+ </div>
+ </div>
+ );
}
+ const jobs = [
+ {
+ title: t("admin.background_jobs.jobs.crawler.title"),
+ icon: Globe,
+ stats: serverStats.crawlStats,
+ description: t("admin.background_jobs.jobs.crawler.description"),
+ actions: actions.crawlActions,
+ },
+ {
+ title: t("admin.background_jobs.jobs.inference.title"),
+ icon: Sparkle,
+ stats: serverStats.inferenceStats,
+ description: t("admin.background_jobs.jobs.inference.description"),
+ actions: actions.inferenceActions,
+ },
+ {
+ title: t("admin.background_jobs.jobs.indexing.title"),
+ icon: Search,
+ stats: { queued: serverStats.indexingStats.queued },
+ description: t("admin.background_jobs.jobs.indexing.description"),
+ actions: actions.indexingActions,
+ },
+ {
+ title: t("admin.background_jobs.jobs.asset_preprocessing.title"),
+ icon: Image,
+ stats: { queued: serverStats.assetPreprocessingStats.queued },
+ description: t(
+ "admin.background_jobs.jobs.asset_preprocessing.description",
+ ),
+ actions: actions.assetPreprocessingActions,
+ },
+ {
+ title: t("admin.background_jobs.jobs.tidy_assets.title"),
+ icon: Database,
+ stats: { queued: serverStats.tidyAssetsStats.queued },
+ description: t("admin.background_jobs.jobs.tidy_assets.description"),
+ actions: actions.tidyAssetsActions,
+ },
+ {
+ title: t("admin.background_jobs.jobs.video.title"),
+ icon: Video,
+ stats: { queued: serverStats.videoStats.queued },
+ description: t("admin.background_jobs.jobs.video.description"),
+ actions: [],
+ },
+ {
+ title: t("admin.background_jobs.jobs.webhook.title"),
+ icon: Webhook,
+ stats: { queued: serverStats.webhookStats.queued },
+ description: t("admin.background_jobs.jobs.webhook.description"),
+ actions: [],
+ },
+ {
+ title: t("admin.background_jobs.jobs.feed.title"),
+ icon: Rss,
+ stats: { queued: serverStats.feedStats.queued },
+ description: t("admin.background_jobs.jobs.feed.description"),
+ actions: [],
+ },
+ ];
+
return (
- <div className="flex flex-col gap-4">
- <div className="mb-2 text-xl font-medium">
- {t("admin.background_jobs.background_jobs")}
- </div>
- <div className="sm:w-1/2">
- <Table className="rounded-md border">
- <TableHeader className="bg-gray-200">
- <TableHead>{t("admin.background_jobs.job")}</TableHead>
- <TableHead>{t("admin.background_jobs.queued")}</TableHead>
- <TableHead>{t("admin.background_jobs.pending")}</TableHead>
- <TableHead>{t("admin.background_jobs.failed")}</TableHead>
- </TableHeader>
- <TableBody>
- <TableRow>
- <TableCell className="lg:w-2/3">
- {t("admin.background_jobs.crawler_jobs")}
- </TableCell>
- <TableCell>{serverStats.crawlStats.queued}</TableCell>
- <TableCell>{serverStats.crawlStats.pending}</TableCell>
- <TableCell>{serverStats.crawlStats.failed}</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>{t("admin.background_jobs.indexing_jobs")}</TableCell>
- <TableCell>{serverStats.indexingStats.queued}</TableCell>
- <TableCell>-</TableCell>
- <TableCell>-</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>{t("admin.background_jobs.inference_jobs")}</TableCell>
- <TableCell>{serverStats.inferenceStats.queued}</TableCell>
- <TableCell>{serverStats.inferenceStats.pending}</TableCell>
- <TableCell>{serverStats.inferenceStats.failed}</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>
- {t("admin.background_jobs.tidy_assets_jobs")}
- </TableCell>
- <TableCell>{serverStats.tidyAssetsStats.queued}</TableCell>
- <TableCell>-</TableCell>
- <TableCell>-</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>{t("admin.background_jobs.video_jobs")}</TableCell>
- <TableCell>{serverStats.videoStats.queued}</TableCell>
- <TableCell>-</TableCell>
- <TableCell>-</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>{t("admin.background_jobs.webhook_jobs")}</TableCell>
- <TableCell>{serverStats.webhookStats.queued}</TableCell>
- <TableCell>-</TableCell>
- <TableCell>-</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>
- {t("admin.background_jobs.asset_preprocessing_jobs")}
- </TableCell>
- <TableCell>
- {serverStats.assetPreprocessingStats.queued}
- </TableCell>
- <TableCell>-</TableCell>
- <TableCell>-</TableCell>
- </TableRow>
- <TableRow>
- <TableCell>{t("admin.background_jobs.feed_jobs")}</TableCell>
- <TableCell>{serverStats.feedStats.queued}</TableCell>
- <TableCell>-</TableCell>
- <TableCell>-</TableCell>
- </TableRow>
- </TableBody>
- </Table>
+ <div className="space-y-6">
+ <AdminCard className="space-y-6">
+ <div className="space-y-2">
+ <h2 className="text-2xl font-semibold tracking-tight">
+ {t("admin.background_jobs.background_jobs")}
+ </h2>
+ <p className="text-muted-foreground">
+ {t("admin.background_jobs.monitor_and_manage")}
+ </p>
+ </div>
+
+ <JobStatusExplanation />
+ </AdminCard>
+
+ <div className="grid gap-6 xl:grid-cols-2">
+ {jobs.map((job, index) => (
+ <JobCard
+ key={index}
+ title={job.title}
+ icon={job.icon}
+ stats={job.stats}
+ description={job.description}
+ actions={job.actions}
+ />
+ ))}
</div>
- <AdminActions />
</div>
);
}