"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 {
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 (
{t("admin.background_jobs.status.title")}
{t("admin.background_jobs.status.queued.title")}
{t("admin.background_jobs.status.queued.description")}
{t("admin.background_jobs.status.unprocessed.title")}
{t("admin.background_jobs.status.unprocessed.description")}
{t("admin.background_jobs.status.failed.title")}
{t("admin.background_jobs.status.failed.description")}
);
}
function JobCardSkeleton() {
return (
);
}
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 (
{title}
{hasActivity && (
{t("admin.background_jobs.active")}
)}
{description}
{t("admin.background_jobs.status.queued.title")}
{stats.queued}
{stats.pending !== undefined && (
{t("admin.background_jobs.status.unprocessed.title")}
{stats.pending}
)}
{stats.failed !== undefined && stats.failed > 0 && (
{t("admin.background_jobs.status.failed.title")}
{stats.failed}
)}
{actions.length > 0 && (
{t("admin.background_jobs.available_actions")}
{actions.map((action, index) => (
{action.label}
))}
)}
);
}
function useJobActions() {
const { t } = useTranslation();
const { mutate: recrawlLinks, isPending: isRecrawlPending } =
api.admin.recrawlLinks.useMutation({
onSuccess: () => {
toast({
description: "Recrawl enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutate: reindexBookmarks, isPending: isReindexPending } =
api.admin.reindexAllBookmarks.useMutation({
onSuccess: () => {
toast({
description: "Reindex enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutate: reprocessAssetsFixMode, isPending: isReprocessingPending } =
api.admin.reprocessAssetsFixMode.useMutation({
onSuccess: () => {
toast({
description: "Reprocessing enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const {
mutate: reRunInferenceOnAllBookmarks,
isPending: isInferencePending,
} = api.admin.reRunInferenceOnAllBookmarks.useMutation({
onSuccess: () => {
toast({
description: "Inference jobs enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } =
api.admin.tidyAssets.useMutation({
onSuccess: () => {
toast({
description: "Tidy assets request has been enqueued!",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
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() {
const { t } = useTranslation();
const { data: serverStats } = api.admin.backgroundJobsStats.useQuery(
undefined,
{
refetchInterval: 1000,
placeholderData: keepPreviousData,
},
);
const actions = useJobActions();
if (!serverStats) {
return (
{Array.from({ length: 8 }).map((_, index) => (
))}
);
}
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 (
{t("admin.background_jobs.background_jobs")}
{t("admin.background_jobs.monitor_and_manage")}
{jobs.map((job, index) => (
))}
);
}