diff options
Diffstat (limited to 'apps/web/components')
| -rw-r--r-- | apps/web/components/admin/AdminCard.tsx | 16 | ||||
| -rw-r--r-- | apps/web/components/admin/BackgroundJobs.tsx | 590 |
2 files changed, 434 insertions, 172 deletions
diff --git a/apps/web/components/admin/AdminCard.tsx b/apps/web/components/admin/AdminCard.tsx index 3a52b5e5..87c10076 100644 --- a/apps/web/components/admin/AdminCard.tsx +++ b/apps/web/components/admin/AdminCard.tsx @@ -1,3 +1,15 @@ -export function AdminCard({ children }: { children: React.ReactNode }) { - return <div className="rounded-md border bg-background p-4">{children}</div>; +import { cn } from "@/lib/utils"; + +export function AdminCard({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return ( + <div className={cn("rounded-md border bg-background p-4", className)}> + {children} + </div> + ); } 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> ); } |
