feat: 公开定时任务页面、Word 导出、定时任务实时刷新与技能详情重构
- 新增 /public/scheduled-tasks/:taskId 公开只读访问页与对应的 /api/public/scheduled-tasks 后端路由(无需登录) - 新增对话整体 / 单条消息 / Markdown 工件 "另存为 Word (.docx)" 能力 - 定时任务列表与运行列表在任务执行中自动轮询刷新,并新增"正在执行"标记 - 定时任务运行消息接口在事件存储为空时回退到 LangGraph checkpoint, 让正在执行的任务也能立即看到提示词与流式输出 - 技能详情对话框由折叠面板改为 Tab 切换(Detail / SKILL.md), 并支持 Rendered / Raw 切换、一键复制 - config.yaml 增加 database (MySQL) 与 checkpointer (SQLite) 配置块 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7e75c5b9b9
commit
d339db4799
18 files changed
+1617
-125
No files matched your search
@@ -0,0 +1,527 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, FileText, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PublicTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
schedule_text: string;
|
||||
cron_expr?: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
published?: boolean;
|
||||
};
|
||||
|
||||
type PublicRunFile = {
|
||||
index: number;
|
||||
name: string;
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
type PublicRunResult = {
|
||||
title: string;
|
||||
content: string;
|
||||
format: "markdown" | "text" | "json" | "mixed";
|
||||
files: PublicRunFile[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type PublicRun = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
scheduled_for: string;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
status: "pending" | "running" | "succeeded" | "failed" | string;
|
||||
error?: string | null;
|
||||
result?: PublicRunResult | null;
|
||||
retry_count?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type PublicRunsResponse = {
|
||||
task: PublicTask;
|
||||
total: number;
|
||||
runs: PublicRun[];
|
||||
};
|
||||
|
||||
type PublicFileContent = {
|
||||
index: number;
|
||||
name: string;
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
size?: number | null;
|
||||
previewable: boolean;
|
||||
content?: string | null;
|
||||
};
|
||||
|
||||
function publicBaseUrl() {
|
||||
return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ detail: "Request failed" }));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse> {
|
||||
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
|
||||
}
|
||||
|
||||
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
|
||||
return fetchJson<PublicFileContent>(
|
||||
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
function publicRunDownloadUrl(runId: string): string {
|
||||
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||
}
|
||||
|
||||
function publicRunFileDownloadUrl(runId: string, index: number): string {
|
||||
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}/download`;
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "等待中";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "succeeded") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean {
|
||||
const name = file.name.toLowerCase();
|
||||
return (
|
||||
file.kind === "markdown" ||
|
||||
file.mime_type === "text/markdown" ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, unauthenticated scheduled-task viewer.
|
||||
*
|
||||
* URL: `#/public/scheduled-tasks/<task_id>`
|
||||
*
|
||||
* Left pane lists every run of the task (latest first). Selecting a run shows
|
||||
* its Markdown body on the right by default; the user can switch tabs to view
|
||||
* generated files or raw metadata.
|
||||
*/
|
||||
export default function PublicScheduledTaskPage() {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["public-scheduled-task", taskId],
|
||||
queryFn: () => fetchPublicTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const runs = useMemo(() => data?.runs ?? [], [data]);
|
||||
const selectedRun = useMemo(
|
||||
() => runs.find((r) => r.id === selectedRunId) ?? null,
|
||||
[runs, selectedRunId],
|
||||
);
|
||||
|
||||
// Files attached to the result that are Markdown. When at least one is
|
||||
// present we render ONLY those — body / meta panels are deliberately hidden
|
||||
// so the public viewer focuses on the deliverable doc.
|
||||
const markdownFiles = useMemo(
|
||||
() => (selectedRun?.result?.files ?? []).filter(isMarkdownFile),
|
||||
[selectedRun],
|
||||
);
|
||||
const hasMarkdownFiles = markdownFiles.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!selectedRun) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = publicRunDownloadUrl(selectedRun.id);
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
const handleDownloadBodyAsWord = async () => {
|
||||
if (!selectedRun?.result?.content) {
|
||||
toast.error("当前记录正文为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadMarkdownAsDocx(
|
||||
selectedRun.result.content,
|
||||
selectedRun.result.title || "scheduled-task-result",
|
||||
selectedRun.result.title || undefined,
|
||||
);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "生成 Word 失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
|
||||
缺少任务 ID。请使用 <code className="mx-1">/public/scheduled-tasks/<task_id></code>。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载任务记录...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-destructive flex h-screen items-center justify-center text-sm">
|
||||
加载失败:{error instanceof Error ? error.message : "未知错误"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const task = data?.task;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<CardHeader className="gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="truncate text-base">
|
||||
{task?.name || "任务"}
|
||||
</CardTitle>
|
||||
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
|
||||
{task?.enabled ? "启用" : "暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{task?.schedule_text} · {task?.timezone}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-0">
|
||||
<ScrollArea className="size-full">
|
||||
<div className="flex flex-col gap-2 p-3 pt-0">
|
||||
{runs.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
这个任务还没有执行记录。
|
||||
</div>
|
||||
)}
|
||||
{runs.map((run) => {
|
||||
const active = run.id === selectedRunId;
|
||||
const runRunning = run.status === "running" || run.status === "pending";
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
className={cn(
|
||||
"bg-card hover:bg-accent/60 rounded-md border p-3 text-left transition-colors",
|
||||
active && "border-primary bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{run.result?.title || formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
计划:{formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={statusVariant(run.status)}
|
||||
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")}
|
||||
>
|
||||
{runRunning && <Loader2 className="size-3 animate-spin" />}
|
||||
{statusLabel(run.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<CardHeader className="flex flex-wrap items-center justify-between gap-2 space-y-0">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{selectedRun?.result?.title || "请选择一条执行记录"}
|
||||
</CardTitle>
|
||||
{selectedRun && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
开始:{formatDate(selectedRun.started_at)} · 完成:
|
||||
{formatDate(selectedRun.finished_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedRun?.result && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadResult}>
|
||||
<Download className="size-4" />
|
||||
下载结果
|
||||
</Button>
|
||||
{!hasMarkdownFiles && selectedRun.result.content && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDownloadBodyAsWord}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{selectedRun?.result || selectedRun?.error ? (
|
||||
hasMarkdownFiles ? (
|
||||
// Result has MD files — show ONLY those. Body, meta, and other
|
||||
// attachments are intentionally hidden so the public viewer is
|
||||
// focused on the deliverable document.
|
||||
<ScrollArea className="size-full">
|
||||
<div className="p-3">
|
||||
<MarkdownFilesPane runId={selectedRun.id} files={markdownFiles} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
// No MD files — fall back to the body Markdown.
|
||||
<ScrollArea className="size-full">
|
||||
<div className="p-4">
|
||||
{selectedRun.error && (
|
||||
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
|
||||
{selectedRun.error}
|
||||
</div>
|
||||
)}
|
||||
{selectedRun.result?.content ? (
|
||||
selectedRun.result.format === "markdown" ||
|
||||
selectedRun.result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={selectedRun.result.content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
{selectedRun.result.content}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
无正文内容。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
|
||||
请在左侧选择一条执行记录。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-only result viewer used by the public page.
|
||||
*
|
||||
* Whenever a run has at least one .md file attached, this is the **only**
|
||||
* thing the right pane shows — body text, metadata, and non-Markdown
|
||||
* attachments are all hidden by the parent. We render each .md file inline
|
||||
* as rich Markdown so anonymous viewers can read it without downloading.
|
||||
* If multiple .md files exist a small switcher at the top lets the user
|
||||
* pick which one is rendered; the others are not exposed.
|
||||
*/
|
||||
function MarkdownFilesPane({
|
||||
runId,
|
||||
files,
|
||||
}: {
|
||||
runId: string;
|
||||
files: PublicRunFile[];
|
||||
}) {
|
||||
const [contents, setContents] = useState<Record<number, PublicFileContent>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadAll() {
|
||||
if (files.length === 0) {
|
||||
setContents({});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const all = await Promise.all(
|
||||
files.map((f) => fetchPublicRunFile(runId, f.index)),
|
||||
);
|
||||
if (!cancelled) {
|
||||
const map: Record<number, PublicFileContent> = {};
|
||||
for (const item of all) map[item.index] = item;
|
||||
setContents(map);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
toast.error(err instanceof Error ? err.message : "加载文件内容失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void loadAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [runId, files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex == null && files.length > 0) {
|
||||
setActiveIndex(files[0]!.index);
|
||||
}
|
||||
if (activeIndex != null && !files.some((f) => f.index === activeIndex)) {
|
||||
setActiveIndex(files[0]?.index ?? null);
|
||||
}
|
||||
}, [activeIndex, files]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeFile =
|
||||
activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null;
|
||||
const activeContent = activeFile ? contents[activeFile.index] : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{files.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2 border-b pb-2">
|
||||
{files.map((file) => {
|
||||
const active = file.index === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={file.index}
|
||||
onClick={() => setActiveIndex(file.index)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1 text-xs transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "bg-card hover:bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<FileText className="mr-1 inline size-3" />
|
||||
{file.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{activeFile && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{activeFile.name}
|
||||
{typeof activeFile.size === "number" ? ` · ${activeFile.size} bytes` : ""}
|
||||
</div>
|
||||
<a
|
||||
className="bg-background hover:bg-accent inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs"
|
||||
href={publicRunFileDownloadUrl(runId, activeFile.index)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
下载
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{loading && !activeContent && (
|
||||
<div className="text-muted-foreground text-sm">正在加载内容...</div>
|
||||
)}
|
||||
{activeContent ? (
|
||||
activeContent.previewable && activeContent.content ? (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={activeContent.content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
无法预览此 Markdown 文件,请下载查看。
|
||||
</div>
|
||||
)
|
||||
) : !loading ? (
|
||||
<div className="text-muted-foreground text-sm">未加载到内容。</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Globe,
|
||||
Heart,
|
||||
HeartOff,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -304,6 +305,9 @@ export default function ScheduledTasksPage() {
|
||||
const isOwner = task.role !== "subscriber";
|
||||
const isSubscriber = task.role === "subscriber";
|
||||
const notifyOn = !!task._subscription?.notify_element;
|
||||
const isRunningNow =
|
||||
task.last_status === "running" ||
|
||||
task.last_status === "pending";
|
||||
return (
|
||||
<button
|
||||
key={task.task_id}
|
||||
@@ -323,6 +327,15 @@ export default function ScheduledTasksPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate font-medium">{task.name}</div>
|
||||
{isRunningNow && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="border-primary/30 bg-primary/10 text-primary shrink-0 text-xs"
|
||||
>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
正在执行
|
||||
</Badge>
|
||||
)}
|
||||
{isSubscriber && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
<Heart className="size-3" />收藏
|
||||
@@ -345,7 +358,12 @@ export default function ScheduledTasksPage() {
|
||||
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
|
||||
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
||||
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
||||
<div>最近状态:{statusLabel(task.last_status)}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>最近状态:{statusLabel(task.last_status)}</span>
|
||||
{isRunningNow && (
|
||||
<Loader2 className="text-primary size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
{(task.creator_email || task.creator_user_id) && (
|
||||
<div className="truncate">
|
||||
创建者:
|
||||
@@ -583,10 +601,16 @@ export default function ScheduledTasksPage() {
|
||||
请先选择一个定时任务。
|
||||
</div>
|
||||
)}
|
||||
{filteredRuns.map((run) => (
|
||||
{filteredRuns.map((run) => {
|
||||
const runRunning =
|
||||
run.status === "running" || run.status === "pending";
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
|
||||
className={cn(
|
||||
"rounded-md border p-4 transition-colors hover:bg-accent/40",
|
||||
runRunning && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -598,7 +622,11 @@ export default function ScheduledTasksPage() {
|
||||
{formatDate(run.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusVariant(run.status)}>
|
||||
<Badge
|
||||
variant={statusVariant(run.status)}
|
||||
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")}
|
||||
>
|
||||
{runRunning && <Loader2 className="size-3 animate-spin" />}
|
||||
{statusLabel(run.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -641,7 +669,8 @@ export default function ScheduledTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
|
||||
Reference in new issue
Block a user