diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/admin/background_jobs/page.tsx | 7 | ||||
| -rw-r--r-- | apps/web/components/admin/AdminCard.tsx | 16 | ||||
| -rw-r--r-- | apps/web/components/admin/BackgroundJobs.tsx | 590 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 88 |
4 files changed, 499 insertions, 202 deletions
diff --git a/apps/web/app/admin/background_jobs/page.tsx b/apps/web/app/admin/background_jobs/page.tsx index 92b9e370..6a13dd64 100644 --- a/apps/web/app/admin/background_jobs/page.tsx +++ b/apps/web/app/admin/background_jobs/page.tsx @@ -1,10 +1,5 @@ -import { AdminCard } from "@/components/admin/AdminCard"; import BackgroundJobs from "@/components/admin/BackgroundJobs"; export default function BackgroundJobsPage() { - return ( - <AdminCard> - <BackgroundJobs /> - </AdminCard> - ); + return <BackgroundJobs />; } 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> ); } diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 10b2f390..21268320 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -317,31 +317,71 @@ "server_version": "Server Version" }, "background_jobs": { + "jobs": { + "crawler": { + "title": "Crawler Jobs", + "description": "Web crawling and content extraction from URLs" + }, + "inference": { + "title": "Inference Jobs", + "description": "AI-powered tagging and summarization of content" + }, + "indexing": { + "title": "Indexing Jobs", + "description": "Search index updates" + }, + "asset_preprocessing": { + "title": "Asset Preprocessing Jobs", + "description": "Image and document preprocessing (screenshots, text extraction, etc.)" + }, + "tidy_assets": { + "title": "Tidy Assets Jobs", + "description": "Asset cleanup and storage optimization" + }, + "video": { + "title": "Video Download Jobs", + "description": "Video extraction and download" + }, + "webhook": { + "title": "Webhook Jobs", + "description": "External webhook notifications" + }, + "feed": { + "title": "RSS Feed Jobs", + "description": "RSS feed processing and content updates" + } + }, "background_jobs": "Background Jobs", - "crawler_jobs": "Crawler Jobs", - "indexing_jobs": "Indexing Jobs", - "inference_jobs": "Inference Jobs", - "tidy_assets_jobs": "Tidy Assets Jobs", - "video_jobs": "Video Download Jobs", - "webhook_jobs": "Webhook Jobs", - "asset_preprocessing_jobs": "Asset Preprocessing Jobs", - "feed_jobs": "RSS Feed Jobs", - "job": "Job", - "queued": "Queued", - "pending": "Pending", - "failed": "Failed" - }, - "actions": { - "recrawl_failed_links_only": "Recrawl Failed Links Only", - "recrawl_all_links": "Recrawl All Links", - "without_inference": "Without Inference", - "regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only", - "regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks", - "regenerate_ai_summaries_for_failed_bookmarks_only": "Regenerate AI Summaries for Failed Bookmarks Only", - "regenerate_ai_summaries_for_all_bookmarks": "Regenerate AI Summaries for All Bookmarks", - "reindex_all_bookmarks": "Reindex All Bookmarks", - "compact_assets": "Compact Assets", - "reprocess_assets_fix_mode": "Reprocess Assets (Fix Mode)" + "monitor_and_manage": "Monitor and manage background job queues and system processing tasks", + "active": "Active", + "available_actions": "Available Actions", + "status": { + "title": "Understanding Job States", + "queued": { + "title": "Queued", + "description": "Jobs waiting in line to be processed. They will start automatically when resources are available." + }, + "unprocessed": { + "title": "Unprocessed", + "description": "Bookmarks that have not yet been processed. They are most likely already queued for processing, if not, you might need to manually re-enqueue them." + }, + "failed": { + "title": "Failed", + "description": "Bookmarks that encountered errors during processing. These may need manual attention." + } + }, + "actions": { + "recrawl_failed_links_only": "Recrawl Failed Links Only", + "recrawl_all_links": "Recrawl All Links", + "without_inference": "Without Inference", + "regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only", + "regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks", + "regenerate_ai_summaries_for_failed_bookmarks_only": "Regenerate AI Summaries for Failed Bookmarks Only", + "regenerate_ai_summaries_for_all_bookmarks": "Regenerate AI Summaries for All Bookmarks", + "reindex_all_bookmarks": "Reindex All Bookmarks", + "clean_assets": "Clean Dangling Assets & Re-sync Metadata", + "reprocess_assets_fix_mode": "Reprocess Unprocessed Assets" + } }, "users_list": { "users_list": "Users List", |
