feat(ui): refactor Provider, Cron, Channels pages with modern UX

- Add detail pages with sticky headers, bordered overview sections,
  and advanced config dialogs for Provider, Cron, and Channels
- Add list-row components for compact list views (no card view)
- Add Provider detail route (/providers/:id)
- Add cron job edit capability (updateJob via CRON_UPDATE RPC)
- Split Channel config into essential (Overview tab) vs advanced (dialog)
- Remove Channel Config tab, merge essential fields into General tab
- Add ViewModeToggle shared component (used by Agent/Team pages)
- Add i18n keys for all new UI elements (en/vi/zh)
- Simplify provider-form-dialog to create-only mode
This commit is contained in:
viettranx committed 2026-03-20 13:04:52 +07:00
1 parent 6e94c5fdf8
commit f90a5f0974
40 files changed
+2748 -1077

No files matched your search

@@ -0,0 +1,46 @@
import { LayoutGrid, List } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface ViewModeToggleProps {
mode: "card" | "list";
onChange: (mode: "card" | "list") => void;
}
export function ViewModeToggle({ mode, onChange }: ViewModeToggleProps) {
const { t } = useTranslation("common");
return (
<TooltipProvider>
<div className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={mode === "card" ? "default" : "ghost"}
size="xs"
className="h-7 w-7 p-0"
onClick={() => onChange("card")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("viewCard")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={mode === "list" ? "default" : "ghost"}
size="xs"
className="h-7 w-7 p-0"
onClick={() => onChange("list")}
>
<List className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("viewList")}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
}
+15
View File
@@ -74,6 +74,20 @@
},
"detail": {
"agent": "Agent: {{name}}",
"advanced": "Advanced",
"advancedTitle": "Advanced Settings",
"policies": "Policies",
"policiesDesc": "Message handling policies",
"network": "Network",
"networkDesc": "Server and proxy configuration",
"limits": "Limits",
"limitsDesc": "Message and media constraints",
"streaming": "Streaming",
"streamingDesc": "Real-time message streaming options",
"behavior": "Behavior",
"behaviorDesc": "Response behavior configuration",
"accessControl": "Access Control",
"accessControlDesc": "User access restrictions",
"tabs": {
"general": "General",
"config": "Config",
@@ -82,6 +96,7 @@
"managers": "Managers"
},
"general": {
"identity": "Identity",
"name": "Name",
"nameHint": "Unique slug (read-only)",
"channelType": "Channel Type",
+3 -1
View File
@@ -116,5 +116,7 @@
"failedToLoadImage": "Failed to load image",
"deleteFolder": "Delete folder",
"deleteFile": "Delete file",
"loadMore": "Load more..."
"loadMore": "Load more...",
"viewCard": "Card view",
"viewList": "List view"
}
+44 -2
View File
@@ -16,6 +16,14 @@
"running": "Running...",
"runHistory": "Run history",
"defaultAgent": "default",
"searchPlaceholder": "Search cron jobs...",
"noMatchTitle": "No matching jobs",
"noMatchDescription": "Try a different search term.",
"card": {
"nextRun": "Next: {{time}}",
"noNextRun": "No next run",
"defaultAgent": "Default agent"
},
"create": {
"title": "Create Cron Job",
"name": "Name",
@@ -81,11 +89,43 @@
"timezone": "Timezone",
"nextRun": "Next Run",
"lastRun": "Last Run",
"lastStatus": "Last Status",
"created": "Created",
"updated": "Updated",
"autoDelete": "Auto-delete",
"autoDeleteValue": "Yes (one-time)"
}
},
"tabs": {
"overview": "Overview",
"history": "Run History"
},
"advanced": "Advanced",
"saveFailed": "Failed to save",
"schedule": "Schedule",
"scheduleDesc": "Timing configuration",
"messageSection": "Message",
"messageSectionDesc": "What the agent should do",
"agentStatus": "Agent & Status",
"agentStatusDesc": "Agent assignment and job status",
"info": "Information",
"infoDesc": "Job metadata",
"scheduling": "Scheduling",
"schedulingDesc": "Timezone configuration",
"delivery": "Delivery",
"deliveryDesc": "Route job output to a channel or user",
"deliverToChannel": "Deliver to channel",
"channelLabel": "Channel",
"channelPlaceholder": "#general",
"toLabel": "To (user)",
"toPlaceholder": "user-id",
"wakeHeartbeat": "Wake heartbeat on run",
"wakeHeartbeatDesc": "Trigger agent heartbeat when job runs",
"lifecycle": "Lifecycle",
"lifecycleDesc": "Job lifecycle settings",
"deleteAfterRun": "Delete after run",
"deleteAfterRunDesc": "Auto-delete this job after first successful run",
"timezone": "Timezone",
"timezoneDesc": "Timezone for cron expressions"
},
"toast": {
"created": "Cron job created",
@@ -97,6 +137,8 @@
"failedCreate": "Failed to create cron job",
"failedToggle": "Failed to toggle cron job",
"failedDelete": "Failed to delete cron job",
"failedRun": "Failed to run cron job"
"failedRun": "Failed to run cron job",
"updated": "Job updated",
"failedUpdate": "Failed to update job"
}
}
+29
View File
@@ -19,6 +19,15 @@
"oauthToken": "OAuth Token",
"notSet": "Not set"
},
"card": {
"apiKeySet": "API key set",
"authenticated": "Authenticated",
"oauthLinked": "OAuth linked"
},
"detail": {
"title": "Provider Details",
"advanced": "Advanced"
},
"form": {
"createTitle": "Add Provider",
"editTitle": "Edit Provider",
@@ -124,6 +133,26 @@
"runOnServer": "Run on the server terminal:",
"recheckButton": "Re-check"
},
"detail": {
"advanced": "Advanced",
"identity": "Identity",
"identityDesc": "Provider identification",
"connection": "Connection",
"connectionDesc": "API endpoint configuration",
"acpConfig": "ACP Configuration",
"acpConfigDesc": "Agent subprocess settings",
"cliConfig": "Claude CLI",
"cliConfigDesc": "Local CLI authentication",
"oauthConfig": "OAuth",
"oauthConfigDesc": "ChatGPT OAuth authentication",
"nameReadonly": "Provider identifier, cannot be changed",
"providerType": "Provider Type",
"apiKeySection": "API Key",
"apiKeySectionDesc": "Authentication credentials",
"statusSection": "Status",
"statusSectionDesc": "Provider availability",
"enabledDesc": "Provider is active and available for agents"
},
"toast": {
"created": "Provider created",
"createdDesc": "{{name}} has been added",
+15
View File
@@ -74,6 +74,20 @@
},
"detail": {
"agent": "Agent: {{name}}",
"advanced": "Nâng cao",
"advancedTitle": "Cài đặt nâng cao",
"policies": "Chính sách",
"policiesDesc": "Chính sách xử lý tin nhắn",
"network": "Mạng",
"networkDesc": "Cấu hình máy chủ và proxy",
"limits": "Giới hạn",
"limitsDesc": "Giới hạn tin nhắn và media",
"streaming": "Streaming",
"streamingDesc": "Tùy chọn streaming tin nhắn thời gian thực",
"behavior": "Hành vi",
"behaviorDesc": "Cấu hình hành vi phản hồi",
"accessControl": "Kiểm soát truy cập",
"accessControlDesc": "Giới hạn truy cập người dùng",
"tabs": {
"general": "Chung",
"config": "Cấu hình",
@@ -82,6 +96,7 @@
"managers": "Quản lý"
},
"general": {
"identity": "Thông tin",
"name": "Tên",
"nameHint": "Slug duy nhất (chỉ đọc)",
"channelType": "Loại channel",
+3 -1
View File
@@ -116,5 +116,7 @@
"failedToLoadImage": "Không thể tải hình ảnh",
"deleteFolder": "Xóa thư mục",
"deleteFile": "Xóa tệp",
"loadMore": "Tải thêm..."
"loadMore": "Tải thêm...",
"viewCard": "Dạng thẻ",
"viewList": "Dạng danh sách"
}
+44 -2
View File
@@ -16,6 +16,14 @@
"running": "Đang chạy...",
"runHistory": "Lịch sử chạy",
"defaultAgent": "mặc định",
"searchPlaceholder": "Tìm kiếm cron...",
"noMatchTitle": "Không tìm thấy tác vụ",
"noMatchDescription": "Thử từ khóa khác.",
"card": {
"nextRun": "Tiếp: {{time}}",
"noNextRun": "Không có lần chạy tiếp",
"defaultAgent": "Agent mặc định"
},
"create": {
"title": "Tạo tác vụ định kỳ",
"name": "Tên",
@@ -81,11 +89,43 @@
"timezone": "Múi giờ",
"nextRun": "Lần chạy tiếp theo",
"lastRun": "Lần chạy gần nhất",
"lastStatus": "Trạng thái",
"created": "Đã tạo",
"updated": "Đã cập nhật",
"autoDelete": "Tự động xóa",
"autoDeleteValue": "Có (một lần)"
}
},
"tabs": {
"overview": "Tổng quan",
"history": "Lịch sử chạy"
},
"advanced": "Nâng cao",
"saveFailed": "Lưu thất bại",
"schedule": "Lịch",
"scheduleDesc": "Cấu hình thời gian",
"messageSection": "Tin nhắn",
"messageSectionDesc": "Nội dung agent cần thực hiện",
"agentStatus": "Agent & Trạng thái",
"agentStatusDesc": "Gán agent và trạng thái công việc",
"info": "Thông tin",
"infoDesc": "Siêu dữ liệu công việc",
"scheduling": "Lên lịch",
"schedulingDesc": "Cấu hình múi giờ",
"delivery": "Giao nhận",
"deliveryDesc": "Chuyển kết quả công việc đến kênh hoặc người dùng",
"deliverToChannel": "Gửi đến kênh",
"channelLabel": "Kênh",
"channelPlaceholder": "#general",
"toLabel": "Đến (người dùng)",
"toPlaceholder": "user-id",
"wakeHeartbeat": "Kích hoạt heartbeat khi chạy",
"wakeHeartbeatDesc": "Kích hoạt heartbeat của agent khi công việc chạy",
"lifecycle": "Vòng đời",
"lifecycleDesc": "Cài đặt vòng đời công việc",
"deleteAfterRun": "Xóa sau khi chạy",
"deleteAfterRunDesc": "Tự động xóa công việc sau lần chạy thành công đầu tiên",
"timezone": "Múi giờ",
"timezoneDesc": "Múi giờ cho biểu thức cron"
},
"toast": {
"created": "Đã tạo tác vụ định kỳ",
@@ -97,6 +137,8 @@
"failedCreate": "Không thể tạo tác vụ định kỳ",
"failedToggle": "Không thể chuyển đổi tác vụ định kỳ",
"failedDelete": "Không thể xóa tác vụ định kỳ",
"failedRun": "Không thể chạy tác vụ định kỳ"
"failedRun": "Không thể chạy tác vụ định kỳ",
"updated": "Đã cập nhật job",
"failedUpdate": "Cập nhật job thất bại"
}
}
+29
View File
@@ -19,6 +19,15 @@
"oauthToken": "OAuth Token",
"notSet": "Chưa thiết lập"
},
"card": {
"apiKeySet": "Đã đặt API key",
"authenticated": "Đã xác thực",
"oauthLinked": "Đã liên kết OAuth"
},
"detail": {
"title": "Chi tiết Provider",
"advanced": "Nâng cao"
},
"form": {
"createTitle": "Thêm provider",
"editTitle": "Chỉnh sửa provider",
@@ -124,6 +133,26 @@
"runOnServer": "Chạy trên terminal máy chủ:",
"recheckButton": "Kiểm tra lại"
},
"detail": {
"advanced": "Nâng cao",
"identity": "Danh tính",
"identityDesc": "Nhận dạng provider",
"connection": "Kết nối",
"connectionDesc": "Cấu hình endpoint API",
"acpConfig": "Cấu hình ACP",
"acpConfigDesc": "Cài đặt tiến trình con agent",
"cliConfig": "Claude CLI",
"cliConfigDesc": "Xác thực CLI cục bộ",
"oauthConfig": "OAuth",
"oauthConfigDesc": "Xác thực OAuth ChatGPT",
"nameReadonly": "Định danh provider, không thể thay đổi",
"providerType": "Loại provider",
"apiKeySection": "API Key",
"apiKeySectionDesc": "Thông tin xác thực",
"statusSection": "Trạng thái",
"statusSectionDesc": "Tình trạng hoạt động của provider",
"enabledDesc": "Provider đang hoạt động và sẵn sàng cho các agent"
},
"toast": {
"created": "Đã tạo provider",
"createdDesc": "{{name}} đã được thêm",
+15
View File
@@ -74,6 +74,20 @@
},
"detail": {
"agent": "Agent: {{name}}",
"advanced": "高级设置",
"advancedTitle": "高级配置",
"policies": "策略",
"policiesDesc": "消息处理策略",
"network": "网络",
"networkDesc": "服务器和代理配置",
"limits": "限制",
"limitsDesc": "消息和媒体限制",
"streaming": "流式传输",
"streamingDesc": "实时消息流选项",
"behavior": "行为",
"behaviorDesc": "响应行为配置",
"accessControl": "访问控制",
"accessControlDesc": "用户访问限制",
"tabs": {
"general": "基本信息",
"config": "配置",
@@ -82,6 +96,7 @@
"managers": "管理员"
},
"general": {
"identity": "身份信息",
"name": "名称",
"nameHint": "唯一标识(只读)",
"channelType": "Channel类型",
+3 -1
View File
@@ -116,5 +116,7 @@
"failedToLoadImage": "图片加载失败",
"deleteFolder": "删除文件夹",
"deleteFile": "删除文件",
"loadMore": "加载更多..."
"loadMore": "加载更多...",
"viewCard": "卡片视图",
"viewList": "列表视图"
}
+44 -2
View File
@@ -16,6 +16,14 @@
"running": "运行中...",
"runHistory": "运行历史",
"defaultAgent": "默认",
"searchPlaceholder": "搜索定时任务...",
"noMatchTitle": "未找到匹配任务",
"noMatchDescription": "请尝试其他关键词。",
"card": {
"nextRun": "下次: {{time}}",
"noNextRun": "无下次运行",
"defaultAgent": "默认代理"
},
"create": {
"title": "创建定时任务",
"name": "名称",
@@ -81,11 +89,43 @@
"timezone": "时区",
"nextRun": "下次运行",
"lastRun": "上次运行",
"lastStatus": "最后状态",
"created": "创建时间",
"updated": "更新时间",
"autoDelete": "自动删除",
"autoDeleteValue": "是(一次性)"
}
},
"tabs": {
"overview": "概览",
"history": "运行历史"
},
"advanced": "高级",
"saveFailed": "保存失败",
"schedule": "计划",
"scheduleDesc": "时间配置",
"messageSection": "消息",
"messageSectionDesc": "Agent应执行的内容",
"agentStatus": "Agent & 状态",
"agentStatusDesc": "Agent分配和任务状态",
"info": "信息",
"infoDesc": "任务元数据",
"scheduling": "调度",
"schedulingDesc": "时区配置",
"delivery": "投递",
"deliveryDesc": "将任务输出路由到频道或用户",
"deliverToChannel": "投递到频道",
"channelLabel": "频道",
"channelPlaceholder": "#general",
"toLabel": "至(用户)",
"toPlaceholder": "user-id",
"wakeHeartbeat": "运行时唤醒心跳",
"wakeHeartbeatDesc": "任务运行时触发Agent心跳",
"lifecycle": "生命周期",
"lifecycleDesc": "任务生命周期设置",
"deleteAfterRun": "运行后删除",
"deleteAfterRunDesc": "首次成功运行后自动删除此任务",
"timezone": "时区",
"timezoneDesc": "Cron表达式的时区"
},
"toast": {
"created": "定时任务已创建",
@@ -97,6 +137,8 @@
"failedCreate": "创建定时任务失败",
"failedToggle": "切换定时任务失败",
"failedDelete": "删除定时任务失败",
"failedRun": "运行定时任务失败"
"failedRun": "运行定时任务失败",
"updated": "任务已更新",
"failedUpdate": "更新任务失败"
}
}
+29
View File
@@ -19,6 +19,15 @@
"oauthToken": "OAuth 令牌",
"notSet": "未设置"
},
"card": {
"apiKeySet": "已设置API密钥",
"authenticated": "已认证",
"oauthLinked": "已链接OAuth"
},
"detail": {
"title": "提供商详情",
"advanced": "高级"
},
"form": {
"createTitle": "添加Provider",
"editTitle": "编辑Provider",
@@ -124,6 +133,26 @@
"runOnServer": "在服务器终端运行:",
"recheckButton": "重新检查"
},
"detail": {
"advanced": "高级设置",
"identity": "身份",
"identityDesc": "Provider标识",
"connection": "连接",
"connectionDesc": "API端点配置",
"acpConfig": "ACP配置",
"acpConfigDesc": "Agent子进程设置",
"cliConfig": "Claude CLI",
"cliConfigDesc": "本地CLI认证",
"oauthConfig": "OAuth",
"oauthConfigDesc": "ChatGPT OAuth认证",
"nameReadonly": "Provider标识符,无法更改",
"providerType": "Provider类型",
"apiKeySection": "API密钥",
"apiKeySectionDesc": "认证凭证",
"statusSection": "状态",
"statusSectionDesc": "Provider可用性",
"enabledDesc": "Provider已激活并可供Agent使用"
},
"toast": {
"created": "Provider已创建",
"createdDesc": "{{name}} 已添加",
+1
View File
@@ -22,6 +22,7 @@ export const ROUTES = {
NODES: "/nodes",
LOGS: "/logs",
PROVIDERS: "/providers",
PROVIDER_DETAIL: "/providers/:id",
TEAMS: "/teams",
TEAM_DETAIL: "/teams/:id",
CUSTOM_TOOLS: "/custom-tools",
@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { ChannelFields } from "../channel-fields";
import { configSchema } from "../channel-schemas";
import type { ChannelInstanceData } from "@/types/channel";
interface ChannelAdvancedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
instance: ChannelInstanceData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention"]);
const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]);
const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]);
const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_stream", "native_stream", "debounce_delay", "thread_ttl"]);
const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "block_reply", "render_mode", "topic_session_mode"]);
const ACCESS_KEYS = new Set(["allow_from", "group_allow_from"]);
function getAdvancedFields(channelType: string) {
const allFields = configSchema[channelType] ?? [];
const advanced = allFields.filter((f) => !ESSENTIAL_CONFIG_KEYS.has(f.key));
return {
network: advanced.filter((f) => NETWORK_KEYS.has(f.key)),
limits: advanced.filter((f) => LIMITS_KEYS.has(f.key)),
streaming: advanced.filter((f) => STREAMING_KEYS.has(f.key)),
behavior: advanced.filter((f) => BEHAVIOR_KEYS.has(f.key)),
access: advanced.filter((f) => ACCESS_KEYS.has(f.key)),
};
}
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
const config = (instance.config ?? {}) as Record<string, unknown>;
const { groups: _groups, ...rest } = config as Record<string, unknown> & { groups?: unknown };
// Only keep advanced keys (exclude essential + groups)
return Object.fromEntries(
Object.entries(rest).filter(([k]) => !ESSENTIAL_CONFIG_KEYS.has(k)),
);
}
export function ChannelAdvancedDialog({
open,
onOpenChange,
instance,
onUpdate,
}: ChannelAdvancedDialogProps) {
const { t } = useTranslation("channels");
const groups = getAdvancedFields(instance.channel_type);
const [values, setValues] = useState<Record<string, unknown>>(() => deriveInitialValues(instance));
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Re-sync local state when dialog opens
useEffect(() => {
if (!open) return;
setValues(deriveInitialValues(instance));
setSaveError(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
const existingConfig = (instance.config ?? {}) as Record<string, unknown>;
const cleanAdvanced = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
// Merge: preserve essential keys and groups from existing, overwrite advanced keys
const merged = { ...existingConfig, ...cleanAdvanced };
await onUpdate({ config: merged });
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("form.errors.failedSaveConfig"));
} finally {
setSaving(false);
}
};
const hasAnyGroup = Object.values(groups).some((g) => g.length > 0);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
{t("detail.advancedTitle")}
</DialogTitle>
</DialogHeader>
{/* Scrollable body */}
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
{!hasAnyGroup && (
<p className="text-sm text-muted-foreground">{t("detail.config.noSchema")}</p>
)}
{groups.network.length > 0 && (
<>
<ConfigGroupHeader
title={t("detail.network")}
description={t("detail.networkDesc")}
/>
<ChannelFields
fields={groups.network}
values={values}
onChange={handleChange}
idPrefix="adv-net"
contextValues={values}
/>
</>
)}
{groups.limits.length > 0 && (
<>
<ConfigGroupHeader
title={t("detail.limits")}
description={t("detail.limitsDesc")}
/>
<ChannelFields
fields={groups.limits}
values={values}
onChange={handleChange}
idPrefix="adv-lim"
/>
</>
)}
{groups.streaming.length > 0 && (
<>
<ConfigGroupHeader
title={t("detail.streaming")}
description={t("detail.streamingDesc")}
/>
<ChannelFields
fields={groups.streaming}
values={values}
onChange={handleChange}
idPrefix="adv-str"
/>
</>
)}
{groups.behavior.length > 0 && (
<>
<ConfigGroupHeader
title={t("detail.behavior")}
description={t("detail.behaviorDesc")}
/>
<ChannelFields
fields={groups.behavior}
values={values}
onChange={handleChange}
idPrefix="adv-beh"
/>
</>
)}
{groups.access.length > 0 && (
<>
<ConfigGroupHeader
title={t("detail.accessControl")}
description={t("detail.accessControlDesc")}
/>
<ChannelFields
fields={groups.access}
values={values}
onChange={handleChange}
idPrefix="adv-acc"
/>
</>
)}
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("detail.config.saveConfig")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,132 +0,0 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Check, AlertCircle, Info, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ChannelInstanceData } from "@/types/channel";
import { configSchema } from "../channel-schemas";
import { ChannelFields } from "../channel-fields";
import { ChannelScopesInfo } from "../channel-scopes-info";
interface ChannelConfigTabProps {
instance: ChannelInstanceData;
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
export function ChannelConfigTab({ instance, onUpdate }: ChannelConfigTabProps) {
const { t } = useTranslation("channels");
const config = instance.config ?? {};
// Filter out "groups" from config — managed in separate Groups tab
const { groups: _groups, ...restConfig } = config as Record<string, unknown> & { groups?: unknown };
const [values, setValues] = useState<Record<string, unknown>>(restConfig);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const fields = configSchema[instance.channel_type] ?? [];
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = async () => {
const cleanConfig = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
// Preserve existing groups when saving config
const existingGroups = (instance.config as Record<string, unknown> | null)?.groups;
const merged = existingGroups
? { ...cleanConfig, groups: existingGroups }
: cleanConfig;
setSaving(true);
setSaveError(null);
setSaved(false);
try {
await onUpdate({ config: Object.keys(merged).length > 0 ? merged : null });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
if (fields.length === 0) {
return (
<div className="max-w-2xl">
<p className="text-sm text-muted-foreground">
{t("detail.config.noSchema")}
</p>
</div>
);
}
// Show webhook URL hint for Feishu/Lark in webhook mode
const isFeishu = instance.channel_type === "feishu";
const connectionMode = (values.connection_mode as string) ?? "webhook";
const showWebhookUrl = isFeishu && connectionMode === "webhook";
const webhookPath = (values.webhook_path as string) || "/feishu/events";
const webhookUrl = `https://<your-gateway-domain>${webhookPath}`;
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(webhookPath);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [webhookPath]);
return (
<div className="space-y-6">
<ChannelFields
fields={fields}
values={values}
onChange={handleChange}
idPrefix="cd-cfg"
/>
<ChannelScopesInfo channelType={instance.channel_type} />
{showWebhookUrl && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm dark:border-blue-800 dark:bg-blue-950">
<Info className="h-4 w-4 shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="flex-1 min-w-0">
<p className="text-blue-800 dark:text-blue-200">{t("detail.config.webhookUrlLabel")}</p>
<div className="flex items-center gap-1.5 mt-1">
<code className="text-xs bg-blue-100 dark:bg-blue-900 px-1.5 py-0.5 rounded break-all">
{webhookUrl}
</code>
<button
type="button"
onClick={handleCopy}
className="shrink-0 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
title={t("detail.config.copyPath")}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
)}
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
</div>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {t("detail.config.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("detail.config.saving") : t("detail.config.saveConfig")}
</Button>
</div>
</div>
);
}
@@ -1,26 +1,25 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Radio } from "lucide-react";
import { useChannelDetail } from "../hooks/use-channel-detail";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { ChannelHeader } from "./channel-header";
import { ChannelGeneralTab } from "./channel-general-tab";
import { ChannelCredentialsTab } from "./channel-credentials-tab";
import { ChannelConfigTab } from "./channel-config-tab";
import { ChannelGroupsTab } from "./channel-groups-tab";
import { ChannelManagersTab } from "./channel-managers-tab";
import { ChannelAdvancedDialog } from "./channel-advanced-dialog";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { channelTypeLabels } from "../channels-status-view";
import { useChannels } from "../hooks/use-channels";
interface ChannelDetailPageProps {
instanceId: string;
onBack: () => void;
onDelete?: (instance: { id: string; name: string }) => void;
}
export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps) {
export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetailPageProps) {
const { t } = useTranslation("channels");
const {
instance,
@@ -35,6 +34,8 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
const { agents } = useAgents();
const { channels } = useChannels();
const [activeTab, setActiveTab] = useState("general");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (loading || !instance) {
return <DetailPageSkeleton tabs={4} />;
@@ -48,100 +49,92 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
const isTelegram = instance.channel_type === "telegram";
const handleDelete = () => {
if (onDelete) {
setDeleteOpen(true);
}
};
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="mb-6 flex items-start gap-4">
<Button variant="ghost" size="icon" onClick={onBack} className="mt-0.5 shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Radio className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-xl font-semibold">
{instance.display_name || instance.name}
</h2>
<Badge variant={instance.enabled ? "success" : "secondary"}>
{instance.enabled ? t("enabled") : t("disabled")}
</Badge>
{status && (
<Badge variant={status.running ? "success" : "secondary"}>
{status.running ? t("status.running") : t("status.stopped")}
</Badge>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{instance.display_name && (
<>
<span className="font-mono text-xs">{instance.name}</span>
<span className="text-border">|</span>
</>
)}
<Badge variant="outline" className="text-[11px]">
{channelTypeLabels[instance.channel_type] || instance.channel_type}
</Badge>
<span className="text-border">|</span>
<span>{t("detail.agent", { name: agentName })}</span>
</div>
</div>
</div>
<div>
<ChannelHeader
instance={instance}
status={status}
agentName={agentName}
onBack={onBack}
onAdvanced={() => setAdvancedOpen(true)}
onDelete={handleDelete}
/>
{/* Tabs */}
<div className="max-w-4xl rounded-xl border bg-card p-3 shadow-sm sm:p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="general">{t("detail.tabs.general")}</TabsTrigger>
<TabsTrigger value="credentials">{t("detail.tabs.credentials")}</TabsTrigger>
<TabsTrigger value="config">{t("detail.tabs.config")}</TabsTrigger>
{isTelegram && <TabsTrigger value="groups">{t("detail.tabs.groups")}</TabsTrigger>}
<TabsTrigger value="managers">{t("detail.tabs.managers")}</TabsTrigger>
</TabsList>
<div className="p-3 sm:p-4">
<div className="max-w-4xl">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="general">{t("detail.tabs.general")}</TabsTrigger>
<TabsTrigger value="credentials">{t("detail.tabs.credentials")}</TabsTrigger>
{isTelegram && <TabsTrigger value="groups">{t("detail.tabs.groups")}</TabsTrigger>}
<TabsTrigger value="managers">{t("detail.tabs.managers")}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-4">
<ChannelGeneralTab
instance={instance}
agents={agents}
onUpdate={updateInstance}
/>
</TabsContent>
<TabsContent value="credentials" className="mt-4">
<ChannelCredentialsTab
instance={instance}
onUpdate={updateInstance}
/>
</TabsContent>
<TabsContent value="config" className="mt-4">
<ChannelConfigTab
instance={instance}
onUpdate={updateInstance}
/>
</TabsContent>
{isTelegram && (
<TabsContent value="groups" className="mt-4">
<ChannelGroupsTab
<TabsContent value="general" className="mt-4">
<ChannelGeneralTab
instance={instance}
agents={agents}
onUpdate={updateInstance}
listManagerGroups={listManagerGroups}
/>
</TabsContent>
)}
<TabsContent value="managers" className="mt-4">
<ChannelManagersTab
listManagerGroups={listManagerGroups}
listManagers={listManagers}
addManager={addManager}
removeManager={removeManager}
listContacts={listContacts}
/>
</TabsContent>
</Tabs>
<TabsContent value="credentials" className="mt-4">
<ChannelCredentialsTab
instance={instance}
onUpdate={updateInstance}
/>
</TabsContent>
{isTelegram && (
<TabsContent value="groups" className="mt-4">
<ChannelGroupsTab
instance={instance}
onUpdate={updateInstance}
listManagerGroups={listManagerGroups}
/>
</TabsContent>
)}
<TabsContent value="managers" className="mt-4">
<ChannelManagersTab
listManagerGroups={listManagerGroups}
listManagers={listManagers}
addManager={addManager}
removeManager={removeManager}
listContacts={listContacts}
/>
</TabsContent>
</Tabs>
</div>
</div>
<ChannelAdvancedDialog
open={advancedOpen}
onOpenChange={setAdvancedOpen}
instance={instance}
onUpdate={updateInstance}
/>
{onDelete && (
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("delete.title")}
description={t("delete.description", { name: instance.display_name || instance.name })}
confirmValue={instance.display_name || instance.name}
confirmLabel={t("delete.confirmLabel")}
onConfirm={async () => {
onDelete({ id: instance.id, name: instance.display_name || instance.name });
setDeleteOpen(false);
}}
/>
)}
</div>
);
}
@@ -1,7 +1,5 @@
import { useState } from "react";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save, Check, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -12,10 +10,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { ChannelFields } from "../channel-fields";
import { configSchema } from "../channel-schemas";
import type { ChannelInstanceData } from "@/types/channel";
import type { AgentData } from "@/types/agent";
import { channelTypeLabels } from "../channels-status-view";
const ESSENTIAL_CONFIG_KEYS = ["dm_policy", "group_policy", "require_mention"];
interface ChannelGeneralTabProps {
instance: ChannelInstanceData;
agents: AgentData[];
@@ -24,97 +27,131 @@ interface ChannelGeneralTabProps {
export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneralTabProps) {
const { t } = useTranslation("channels");
const [displayName, setDisplayName] = useState(instance.display_name ?? "");
const [agentId, setAgentId] = useState(instance.agent_id);
const [enabled, setEnabled] = useState(instance.enabled);
// Essential config fields (policies)
const allConfigFields = configSchema[instance.channel_type] ?? [];
const essentialFields = allConfigFields.filter((f) => ESSENTIAL_CONFIG_KEYS.includes(f.key));
const existingConfig = (instance.config ?? {}) as Record<string, unknown>;
const initialPolicyValues = Object.fromEntries(
ESSENTIAL_CONFIG_KEYS
.filter((k) => existingConfig[k] !== undefined)
.map((k) => [k, existingConfig[k]]),
);
const [policyValues, setPolicyValues] = useState<Record<string, unknown>>(initialPolicyValues);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const handlePolicyChange = useCallback((key: string, value: unknown) => {
setPolicyValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
// Merge policy values into existing config, preserving other keys (groups, advanced)
const cleanPolicies = Object.fromEntries(
Object.entries(policyValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
const mergedConfig = { ...existingConfig, ...cleanPolicies };
await onUpdate({
display_name: displayName || null,
agent_id: agentId,
enabled,
config: mergedConfig,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Failed to save");
setSaveError(err instanceof Error ? err.message : t("form.errors.failedSave"));
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
{/* Read-only fields */}
<div className="grid gap-1.5">
<Label>{t("detail.general.name")}</Label>
<Input value={instance.name} disabled />
<p className="text-xs text-muted-foreground">{t("detail.general.nameHint")}</p>
</div>
<div className="space-y-4">
{/* Identity section */}
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.general.identity")}</h3>
<div className="grid gap-1.5">
<Label>{t("detail.general.channelType")}</Label>
<Input value={channelTypeLabels[instance.channel_type] || instance.channel_type} disabled />
</div>
{/* Editable fields */}
<div className="grid gap-1.5">
<Label htmlFor="cd-display">{t("detail.general.displayName")}</Label>
<Input
id="cd-display"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("detail.general.displayNamePlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<Label>{t("detail.general.agent")}</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue placeholder={t("detail.general.selectAgent")} />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch id="cd-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="cd-enabled">{t("detail.general.enabled")}</Label>
</div>
{/* Save */}
{saveError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{saveError}
<div className="grid gap-1.5">
<Label>{t("detail.general.name")}</Label>
<Input value={instance.name} disabled className="text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("detail.general.nameHint")}</p>
</div>
<div className="grid gap-1.5">
<Label>{t("detail.general.channelType")}</Label>
<Input
value={channelTypeLabels[instance.channel_type] || instance.channel_type}
disabled
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cd-display">{t("detail.general.displayName")}</Label>
<Input
id="cd-display"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("detail.general.displayNamePlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label>{t("detail.general.agent")}</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("detail.general.selectAgent")} />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch id="cd-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="cd-enabled">{t("detail.general.enabled")}</Label>
</div>
</section>
{/* Policies section — only shown if this channel type has essential config fields */}
{essentialFields.length > 0 && (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.policies")}</h3>
<ChannelFields
fields={essentialFields}
values={policyValues}
onChange={handlePolicyChange}
idPrefix="cd-pol"
/>
</section>
)}
<div className="flex items-center justify-end gap-2">
{saved && (
<span className="flex items-center gap-1 text-sm text-success">
<Check className="h-3.5 w-3.5" /> {t("detail.general.saved")}
</span>
)}
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("detail.general.saving") : t("detail.general.saveChanges")}
</Button>
</div>
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
label={t("detail.general.saveChanges")}
savingLabel={t("detail.general.saving")}
savedLabel={t("detail.general.saved")}
/>
</div>
);
}
@@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next";
import { ArrowLeft, Radio, Settings, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { channelTypeLabels } from "../channels-status-view";
import type { ChannelInstanceData } from "@/types/channel";
interface ChannelHeaderProps {
instance: ChannelInstanceData;
status: { running: boolean } | null;
agentName: string;
onBack: () => void;
onAdvanced: () => void;
onDelete: () => void;
}
export function ChannelHeader({ instance, status, agentName, onBack, onAdvanced, onDelete }: ChannelHeaderProps) {
const { t } = useTranslation("channels");
const displayTitle = instance.display_name || instance.name;
const typeLabel = channelTypeLabels[instance.channel_type] || instance.channel_type;
return (
<TooltipProvider>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-card px-3 py-2 landscape-compact sm:px-4 sm:gap-3">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 size-9">
<ArrowLeft className="h-4 w-4" />
</Button>
{/* Channel icon */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
<Radio className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
{/* Channel info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h2 className="truncate text-base font-semibold">{displayTitle}</h2>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
instance.enabled ? "bg-emerald-500" : "bg-muted-foreground/50",
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{instance.enabled ? t("enabled") : t("disabled")}
</TooltipContent>
</Tooltip>
{status && (
<Badge variant={status.running ? "success" : "secondary"} className="text-[10px]">
{status.running ? t("status.running") : t("status.stopped")}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span className="font-mono text-[11px]">{instance.name}</span>
<span className="text-border">·</span>
<Badge variant="outline" className="text-[10px]">{typeLabel}</Badge>
<span className="text-border">·</span>
<span>{t("detail.agent", { name: agentName })}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onAdvanced}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
{!instance.is_default && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,93 @@
import { Radio, QrCode, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { ChannelInstanceData } from "@/types/channel";
import { channelTypeLabels } from "./channels-status-view";
import { channelsWithAuth } from "./channel-wizard-registry";
interface ChannelListRowProps {
instance: ChannelInstanceData;
status: { running: boolean } | null;
agentName: string;
onClick: () => void;
onAuth?: () => void;
onDelete?: () => void;
}
export function ChannelListRow({ instance, status, agentName, onClick, onAuth, onDelete }: ChannelListRowProps) {
const { t } = useTranslation("channels");
const displayName = instance.display_name || instance.name;
const isRunning = status?.running ?? false;
return (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-all hover:border-primary/30 hover:shadow-sm"
>
{/* Icon */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Radio className="h-4 w-4" />
</div>
{/* Name + slug */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{displayName}</span>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${
!instance.enabled ? "bg-muted-foreground/40"
: isRunning ? "bg-emerald-500"
: "bg-amber-500"
}`} />
</div>
{instance.display_name && (
<div className="truncate text-xs text-muted-foreground">{instance.name}</div>
)}
</div>
{/* Type */}
<div className="hidden shrink-0 sm:block">
<Badge variant="secondary" className="text-[11px]">
{channelTypeLabels[instance.channel_type] || instance.channel_type}
</Badge>
</div>
{/* Status text */}
<div className="hidden shrink-0 text-xs text-muted-foreground sm:block sm:w-16">
{!instance.enabled
? t("disabled")
: isRunning ? t("status.running") : t("status.stopped")}
</div>
{/* Agent */}
<div className="hidden shrink-0 text-xs text-muted-foreground md:block md:w-28 md:truncate">
{agentName}
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{onAuth && channelsWithAuth.has(instance.channel_type) && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); onAuth(); }}
>
<QrCode className="h-3.5 w-3.5" />
</Button>
)}
{onDelete && !instance.is_default && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</button>
);
}
+29 -104
View File
@@ -1,9 +1,8 @@
import { useState, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Radio, Plus, RefreshCw, Pencil, Trash2, QrCode } from "lucide-react";
import { Radio, Plus, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
@@ -14,8 +13,8 @@ import { useChannels } from "./hooks/use-channels";
import { useChannelInstances, type ChannelInstanceData, type ChannelInstanceInput } from "./hooks/use-channel-instances";
import { ChannelInstanceFormDialog } from "./channel-instance-form-dialog";
import { channelsWithAuth, reauthDialogs } from "./channel-wizard-registry";
import { channelTypeLabels } from "./channels-status-view";
import { ChannelDetailPage } from "./channel-detail/channel-detail-page";
import { ChannelListRow } from "./channel-list-row";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
@@ -72,7 +71,16 @@ export function ChannelsPage() {
// Detail view
if (detailId) {
return <ChannelDetailPage instanceId={detailId} onBack={() => navigate("/channels")} />;
return (
<ChannelDetailPage
instanceId={detailId}
onBack={() => navigate("/channels")}
onDelete={async ({ id }) => {
await deleteInstance(id);
navigate("/channels");
}}
/>
);
}
const handleCreate = async (data: ChannelInstanceInput) => {
@@ -125,7 +133,7 @@ export function ChannelsPage() {
}
/>
<div className="mt-4">
<div className="mt-4 flex flex-wrap items-center gap-2">
<SearchInput
value={search}
onChange={handleSearchChange}
@@ -136,7 +144,7 @@ export function ChannelsPage() {
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
<TableSkeleton />
) : instances.length === 0 ? (
<EmptyState
icon={Radio}
@@ -144,101 +152,20 @@ export function ChannelsPage() {
description={debouncedSearch ? t("noMatchDescription") : t("emptyDescription")}
/>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.type")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.agent")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.enabled")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{instances.map((inst) => {
const status = getStatus(inst.name);
return (
<tr
key={inst.id}
className="border-b last:border-0 hover:bg-muted/30 cursor-pointer"
onClick={() => navigate(`/channels/${inst.id}`)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium">{inst.display_name || inst.name}</span>
{inst.display_name && (
<span className="ml-1 text-xs text-muted-foreground">({inst.name})</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="secondary">
{channelTypeLabels[inst.channel_type] || inst.channel_type}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{getAgentName(inst.agent_id)}
</td>
<td className="px-4 py-3">
{status ? (
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${status.running ? "bg-green-500" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground">
{status.running ? t("status.running") : t("status.stopped")}
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
<td className="px-4 py-3">
<Badge variant={inst.enabled ? "default" : "secondary"}>
{inst.enabled ? t("enabled") : t("disabled")}
</Badge>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{channelsWithAuth.has(inst.channel_type) && (
<Button
variant="ghost"
size="sm"
title={status?.running ? t("actions.reauthenticate") : t("actions.authenticate")}
onClick={(e) => { e.stopPropagation(); setQrTarget(inst); }}
>
<QrCode className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); setEditInstance(inst); setFormOpen(true); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{!inst.is_default && (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); setDeleteTarget(inst); }}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<>
<div className="mt-4 flex flex-col gap-2">
{instances.map((inst) => (
<ChannelListRow
key={inst.id}
instance={inst}
status={getStatus(inst.name)}
agentName={getAgentName(inst.agent_id)}
onClick={() => navigate(`/channels/${inst.id}`)}
onAuth={channelsWithAuth.has(inst.channel_type) ? () => setQrTarget(inst) : undefined}
onDelete={!inst.is_default ? () => setDeleteTarget(inst) : undefined}
/>
))}
</div>
<Pagination
page={page}
pageSize={pageSize}
@@ -247,7 +174,7 @@ export function ChannelsPage() {
onPageChange={setPage}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
</div>
</>
)}
</div>
@@ -287,8 +214,6 @@ export function ChannelsPage() {
instanceName={qrTarget.display_name || qrTarget.name}
onSuccess={() => {
setQrTarget(null);
// Backend reload is async (~2-3s: stop → sleep → restart).
// Refresh after reload has time to complete.
setTimeout(() => refresh(), 3000);
}}
/>
+2 -286
View File
@@ -1,286 +1,2 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, Play, Trash2, Power, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { Pagination } from "@/components/shared/pagination";
import { formatDate, formatTokens } from "@/lib/format";
import type { CronJob, CronRunLogEntry } from "./hooks/use-cron";
function formatScheduleDetail(job: CronJob): string {
const s = job.schedule;
if (s.kind === "every" && s.everyMs) {
const sec = s.everyMs / 1000;
if (sec < 60) return `Every ${sec} seconds`;
if (sec < 3600) return `Every ${Math.round(sec / 60)} minutes`;
return `Every ${(sec / 3600).toFixed(1)} hours`;
}
if (s.kind === "cron" && s.expr) return s.expr;
if (s.kind === "at" && s.atMs) return `Once at ${new Date(s.atMs).toLocaleString()}`;
return s.kind;
}
function formatDuration(ms?: number): string {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
interface CronDetailPageProps {
job: CronJob;
onBack: () => void;
onRun: (id: string) => Promise<void>;
onToggle: (id: string, enabled: boolean) => Promise<void>;
onDelete: (id: string) => Promise<void>;
getRunLog: (id: string, limit?: number, offset?: number) => Promise<{ entries: CronRunLogEntry[]; total: number }>;
onRefresh: () => void;
}
export function CronDetailPage({ job, onBack, onRun, onToggle, onDelete, getRunLog, onRefresh }: CronDetailPageProps) {
const { t } = useTranslation("cron");
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmToggle, setConfirmToggle] = useState(false);
const [runLog, setRunLog] = useState<CronRunLogEntry[]>([]);
const [runLogTotal, setRunLogTotal] = useState(0);
const [runLogLoading, setRunLogLoading] = useState(true);
const [running, setRunning] = useState(false);
const isRunning = running || job.state?.lastStatus === "running";
const [runLogPage, setRunLogPage] = useState(1);
const [runLogPageSize, setRunLogPageSize] = useState(10);
const loadRunLog = useCallback(async (page?: number, pageSize?: number) => {
const p = page ?? runLogPage;
const ps = pageSize ?? runLogPageSize;
setRunLogLoading(true);
try {
const { entries, total } = await getRunLog(job.id, ps, (p - 1) * ps);
setRunLog(entries);
setRunLogTotal(total);
} finally {
setRunLogLoading(false);
}
}, [job.id, getRunLog, runLogPage, runLogPageSize]);
const runLogTotalPages = Math.ceil(runLogTotal / runLogPageSize);
useEffect(() => {
loadRunLog();
}, [loadRunLog]);
// Poll cron list while job is running to detect completion
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(onRefresh, 3000);
return () => clearInterval(interval);
}, [isRunning, onRefresh]);
// Clear local running state when backend status changes from running
useEffect(() => {
if (running && job.state?.lastStatus && job.state.lastStatus !== "running") {
setRunning(false);
loadRunLog(1);
}
}, [running, job.state?.lastStatus, loadRunLog]);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h3 className="flex items-center gap-2 font-medium">
{job.name}
<Badge variant={job.enabled ? "success" : "secondary"}>
{job.enabled ? t("detail.enabled") : t("detail.disabled")}
</Badge>
</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{job.schedule.kind}</Badge>
{job.agentId && <Badge variant="secondary">{job.agentId}</Badge>}
{job.state?.lastStatus && (
<Badge variant={
job.state.lastStatus === "ok" ? "success"
: job.state.lastStatus === "running" ? "outline"
: "destructive"
}>
{job.state.lastStatus === "running" ? t("detail.runningStatus") : job.state.lastStatus}
</Badge>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={isRunning}
onClick={async () => {
setRunning(true);
await onRun(job.id);
}}
>
{isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
{isRunning ? t("detail.running") : t("detail.runNow")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
onClick={() => setConfirmToggle(true)}
>
<Power className="h-3.5 w-3.5" /> {job.enabled ? t("detail.disable") : t("detail.enable")}
</Button>
<Button variant="destructive" size="sm" className="gap-1" onClick={() => setConfirmDelete(true)}>
<Trash2 className="h-3.5 w-3.5" /> {t("detail.delete")}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-3xl space-y-6">
{/* Job Info */}
<div className="grid grid-cols-1 gap-4 rounded-md border p-4 text-sm sm:grid-cols-2">
<InfoRow label={t("detail.infoRows.schedule")} value={formatScheduleDetail(job)} />
{job.schedule.tz && <InfoRow label={t("detail.infoRows.timezone")} value={job.schedule.tz} />}
{job.state?.nextRunAtMs && (
<InfoRow label={t("detail.infoRows.nextRun")} value={formatDate(new Date(job.state.nextRunAtMs))} />
)}
{job.state?.lastRunAtMs && (
<InfoRow label={t("detail.infoRows.lastRun")} value={formatDate(new Date(job.state.lastRunAtMs))} />
)}
<InfoRow label={t("detail.infoRows.created")} value={formatDate(new Date(job.createdAtMs))} />
<InfoRow label={t("detail.infoRows.updated")} value={formatDate(new Date(job.updatedAtMs))} />
{job.deleteAfterRun && <InfoRow label={t("detail.infoRows.autoDelete")} value={t("detail.infoRows.autoDeleteValue")} />}
</div>
{/* Payload */}
<div className="rounded-md border p-4 text-sm">
<h4 className="mb-2 font-medium">{t("detail.payload")}</h4>
<div className="space-y-2">
<div className="rounded bg-muted p-3 font-mono text-xs whitespace-pre-wrap">
{job.payload?.message || t("detail.empty")}
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
{job.payload?.deliver && <span>{t("detail.deliverDirect")}</span>}
{job.payload?.channel && <span>{t("detail.channel", { name: job.payload.channel })}</span>}
{job.payload?.to && <span>{t("detail.to", { name: job.payload.to })}</span>}
</div>
</div>
</div>
{/* Last Error */}
{job.state?.lastError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<h4 className="mb-1 font-medium text-destructive">{t("detail.lastError")}</h4>
<p className="text-xs text-destructive/80">{job.state.lastError}</p>
</div>
)}
{/* Run History */}
<div>
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">{t("detail.runHistory")}</h4>
<Button variant="ghost" size="sm" onClick={() => loadRunLog()} className="text-xs">
{t("detail.refresh")}
</Button>
</div>
{runLogLoading && runLog.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : runLog.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.noHistory")}</p>
) : (
<>
<div className="space-y-2">
{runLog.map((entry, i) => (
<div key={i} className="rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{formatDate(new Date(entry.ts))}</span>
{entry.durationMs != null && entry.durationMs > 0 && (
<span className="text-xs text-muted-foreground">({formatDuration(entry.durationMs)})</span>
)}
</div>
<div className="flex items-center gap-2">
{(entry.inputTokens != null && entry.inputTokens > 0) && (
<span className="text-xs text-muted-foreground">
{t("detail.inOut", { input: formatTokens(entry.inputTokens), output: formatTokens(entry.outputTokens ?? 0) })}
</span>
)}
<Badge variant={entry.status === "ok" || entry.status === "success" ? "success" : "destructive"}>
{entry.status || "unknown"}
</Badge>
</div>
</div>
{entry.summary && (
<p className="mt-1 line-clamp-3 text-muted-foreground">{entry.summary}</p>
)}
{entry.error && (
<p className="mt-1 text-destructive">{entry.error}</p>
)}
</div>
))}
</div>
<Pagination
page={runLogPage}
pageSize={runLogPageSize}
total={runLogTotal}
totalPages={runLogTotalPages}
onPageChange={(p) => { setRunLogPage(p); loadRunLog(p); }}
onPageSizeChange={(s) => { setRunLogPageSize(s); setRunLogPage(1); loadRunLog(1, s); }}
pageSizes={[10, 20, 50]}
/>
</>
)}
</div>
</div>
</div>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title={t("delete.title")}
description={t("delete.description", { name: job.name })}
confirmLabel={t("delete.confirmLabel")}
variant="destructive"
onConfirm={async () => {
await onDelete(job.id);
setConfirmDelete(false);
}}
/>
<ConfirmDialog
open={confirmToggle}
onOpenChange={setConfirmToggle}
title={job.enabled ? t("disable.title") : t("enable.title")}
description={
job.enabled
? t("disable.description", { name: job.name })
: t("enable.description", { name: job.name })
}
confirmLabel={job.enabled ? t("disable.confirmLabel") : t("enable.confirmLabel")}
variant={job.enabled ? "destructive" : "default"}
onConfirm={async () => {
await onToggle(job.id, !job.enabled);
setConfirmToggle(false);
}}
/>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground">{label}</span>
<div className="mt-0.5 font-medium">{value}</div>
</div>
);
}
// Re-export from refactored location — cron-page.tsx imports this path
export { CronDetailPage } from "./cron-detail/cron-detail-page";
@@ -0,0 +1,199 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { IANA_TIMEZONES } from "@/lib/constants";
import type { CronJob } from "../hooks/use-cron";
interface CronAdvancedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
job: CronJob;
onUpdate?: (id: string, params: Record<string, unknown>) => Promise<void>;
}
function deriveState(job: CronJob) {
return {
timezone: job.schedule.tz ?? "UTC",
deliver: job.payload?.deliver ?? false,
channel: job.payload?.channel ?? "",
to: job.payload?.to ?? "",
wakeHeartbeat: Boolean((job as unknown as Record<string, unknown>).wakeHeartbeat),
deleteAfterRun: job.deleteAfterRun ?? false,
};
}
export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAdvancedDialogProps) {
const { t } = useTranslation("cron");
const { t: tc } = useTranslation("common");
const init = deriveState(job);
const [timezone, setTimezone] = useState(init.timezone);
const [deliver, setDeliver] = useState(init.deliver);
const [channel, setChannel] = useState(init.channel);
const [to, setTo] = useState(init.to);
const [wakeHeartbeat, setWakeHeartbeat] = useState(init.wakeHeartbeat);
const [deleteAfterRun, setDeleteAfterRun] = useState(init.deleteAfterRun);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Re-sync when dialog opens
useEffect(() => {
if (!open) return;
const s = deriveState(job);
setTimezone(s.timezone);
setDeliver(s.deliver);
setChannel(s.channel);
setTo(s.to);
setWakeHeartbeat(s.wakeHeartbeat);
setDeleteAfterRun(s.deleteAfterRun);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleSave = async () => {
if (!onUpdate) {
onOpenChange(false);
return;
}
setSaving(true);
setSaveError(null);
try {
await onUpdate(job.id, {
schedule: {
...job.schedule,
tz: timezone !== "UTC" ? timezone : undefined,
},
deliver,
channel: deliver ? channel.trim() || undefined : undefined,
to: deliver ? to.trim() || undefined : undefined,
wakeHeartbeat,
deleteAfterRun,
});
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : tc("save"));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
{t("detail.advanced")}
</DialogTitle>
</DialogHeader>
{/* Scrollable body */}
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
{/* Scheduling */}
<ConfigGroupHeader
title={t("detail.scheduling")}
description={t("detail.schedulingDesc")}
/>
<div className="space-y-2">
<Label htmlFor="adv-timezone">{t("detail.timezone")}</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="adv-timezone" className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IANA_TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("detail.timezoneDesc")}</p>
</div>
{/* Delivery */}
<ConfigGroupHeader
title={t("detail.delivery")}
description={t("detail.deliveryDesc")}
/>
<div className="space-y-3">
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<p className="text-sm font-medium">{t("detail.deliverToChannel")}</p>
<Switch checked={deliver} onCheckedChange={setDeliver} />
</div>
{deliver && (
<>
<div className="space-y-2">
<Label htmlFor="adv-channel">{t("detail.channelLabel")}</Label>
<Input
id="adv-channel"
value={channel}
onChange={(e) => setChannel(e.target.value)}
placeholder={t("detail.channelPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="adv-to">{t("detail.toLabel")}</Label>
<Input
id="adv-to"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder={t("detail.toPlaceholder")}
className="text-base md:text-sm"
/>
</div>
</>
)}
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div>
<p className="text-sm font-medium">{t("detail.wakeHeartbeat")}</p>
<p className="text-xs text-muted-foreground">{t("detail.wakeHeartbeatDesc")}</p>
</div>
<Switch checked={wakeHeartbeat} onCheckedChange={setWakeHeartbeat} />
</div>
</div>
{/* Lifecycle */}
<ConfigGroupHeader
title={t("detail.lifecycle")}
description={t("detail.lifecycleDesc")}
/>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div>
<p className="text-sm font-medium">{t("detail.deleteAfterRun")}</p>
<p className="text-xs text-muted-foreground">{t("detail.deleteAfterRunDesc")}</p>
</div>
<Switch checked={deleteAfterRun} onCheckedChange={setDeleteAfterRun} />
</div>
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tc("cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? tc("saving") : tc("save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import type { CronJob, CronRunLogEntry } from "../hooks/use-cron";
import { CronHeader } from "./cron-header";
import { CronOverviewTab } from "./cron-overview-tab";
import { CronRunHistoryTab } from "./cron-run-history-tab";
import { CronAdvancedDialog } from "./cron-advanced-dialog";
interface CronDetailPageProps {
job: CronJob;
onBack: () => void;
onRun: (id: string) => Promise<void>;
onToggle: (id: string, enabled: boolean) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onUpdate?: (id: string, params: Record<string, unknown>) => Promise<void>;
getRunLog: (id: string, limit?: number, offset?: number) => Promise<{ entries: CronRunLogEntry[]; total: number }>;
onRefresh: () => void;
}
export function CronDetailPage({
job,
onBack,
onRun,
onToggle,
onDelete,
onUpdate,
getRunLog,
onRefresh,
}: CronDetailPageProps) {
const { t } = useTranslation("cron");
const [activeTab, setActiveTab] = useState("overview");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmToggle, setConfirmToggle] = useState(false);
const [running, setRunning] = useState(false);
const isRunning = running || job.state?.lastStatus === "running";
// Poll while running to detect completion
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(onRefresh, 3000);
return () => clearInterval(interval);
}, [isRunning, onRefresh]);
// Clear local running state when backend reports completion
useEffect(() => {
if (running && job.state?.lastStatus && job.state.lastStatus !== "running") {
setRunning(false);
}
}, [running, job.state?.lastStatus]);
const handleRun = useCallback(async () => {
setRunning(true);
await onRun(job.id);
}, [job.id, onRun]);
return (
<div>
<CronHeader
job={job}
isRunning={isRunning}
onBack={onBack}
onRun={handleRun}
onAdvanced={() => setAdvancedOpen(true)}
onToggle={() => setConfirmToggle(true)}
onDelete={() => setConfirmDelete(true)}
/>
<div className="p-3 sm:p-4">
<div className="max-w-4xl">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="overview">{t("detail.tabs.overview")}</TabsTrigger>
<TabsTrigger value="history">{t("detail.tabs.history")}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<CronOverviewTab key={job.id + "-" + job.updatedAtMs} job={job} onUpdate={onUpdate} />
</TabsContent>
<TabsContent value="history" className="mt-4">
<CronRunHistoryTab job={job} getRunLog={getRunLog} onRefresh={onRefresh} />
</TabsContent>
</Tabs>
</div>
</div>
<CronAdvancedDialog
key={job.id}
open={advancedOpen}
onOpenChange={setAdvancedOpen}
job={job}
onUpdate={onUpdate}
/>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title={t("delete.title")}
description={t("delete.description", { name: job.name })}
confirmLabel={t("delete.confirmLabel")}
variant="destructive"
onConfirm={async () => {
await onDelete(job.id);
setConfirmDelete(false);
}}
/>
<ConfirmDialog
open={confirmToggle}
onOpenChange={setConfirmToggle}
title={job.enabled ? t("disable.title") : t("enable.title")}
description={
job.enabled
? t("disable.description", { name: job.name })
: t("enable.description", { name: job.name })
}
confirmLabel={job.enabled ? t("disable.confirmLabel") : t("enable.confirmLabel")}
variant={job.enabled ? "destructive" : "default"}
onConfirm={async () => {
await onToggle(job.id, !job.enabled);
setConfirmToggle(false);
}}
/>
</div>
);
}
@@ -0,0 +1,123 @@
import { useTranslation } from "react-i18next";
import { ArrowLeft, Clock, Loader2, Play, Power, Settings, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { CronJob } from "../hooks/use-cron";
import { formatSchedule } from "../cron-utils";
interface CronHeaderProps {
job: CronJob;
isRunning: boolean;
onBack: () => void;
onRun: () => void;
onAdvanced: () => void;
onToggle: () => void;
onDelete: () => void;
}
export function CronHeader({ job, isRunning, onBack, onRun, onAdvanced, onToggle, onDelete }: CronHeaderProps) {
const { t } = useTranslation("cron");
return (
<TooltipProvider>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-card px-3 py-2 landscape-compact sm:px-4 sm:gap-3">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 size-9">
<ArrowLeft className="h-4 w-4" />
</Button>
{/* Clock icon */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
{/* Job info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h2 className="truncate text-base font-semibold">{job.name}</h2>
<Badge variant={job.enabled ? "success" : "secondary"} className="text-[10px]">
{job.enabled ? t("detail.enabled") : t("detail.disabled")}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
job.enabled ? "bg-emerald-500" : "bg-muted-foreground/50",
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{job.enabled ? t("detail.enabled") : t("detail.disabled")}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<Badge variant="outline" className="text-[10px]">{job.schedule.kind}</Badge>
<span className="text-border">·</span>
<span>{formatSchedule(job)}</span>
{job.agentId && (
<>
<span className="text-border">·</span>
<span className="font-mono text-[11px]">{job.agentId}</span>
</>
)}
</div>
</div>
{/* Run Now */}
<Button
variant="ghost"
size="sm"
onClick={onRun}
disabled={isRunning}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
{isRunning
? <Loader2 className="h-4 w-4 animate-spin" />
: <Play className="h-4 w-4" />}
<span className="hidden sm:inline">
{isRunning ? t("detail.running") : t("detail.runNow")}
</span>
</Button>
{/* Toggle */}
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<span className="hidden sm:inline">
{job.enabled ? t("detail.disable") : t("detail.enable")}
</span>
<Power className="h-4 w-4 sm:hidden" />
</Button>
{/* Advanced */}
<Button
variant="ghost"
size="sm"
onClick={onAdvanced}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
{/* Delete */}
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,225 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { formatDate } from "@/lib/format";
import type { CronJob } from "../hooks/use-cron";
interface CronOverviewTabProps {
job: CronJob;
onUpdate?: (id: string, params: Record<string, unknown>) => Promise<void>;
}
type ScheduleKind = "every" | "cron" | "at";
function getEverySeconds(job: CronJob): string {
if (job.schedule.kind === "every" && job.schedule.everyMs) {
return String(job.schedule.everyMs / 1000);
}
return "60";
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground text-xs">{label}</span>
<div className="mt-0.5 text-sm font-medium">{value}</div>
</div>
);
}
export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
const { t } = useTranslation("cron");
const readonly = !onUpdate;
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>(job.schedule.kind as ScheduleKind);
const [everySeconds, setEverySeconds] = useState(getEverySeconds(job));
const [cronExpr, setCronExpr] = useState(job.schedule.expr ?? "0 * * * *");
const [message, setMessage] = useState(job.payload?.message ?? "");
const [agentId, setAgentId] = useState(job.agentId ?? "");
const [enabled, setEnabled] = useState(job.enabled);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
if (!onUpdate) return;
setSaving(true);
setSaveError(null);
setSaved(false);
try {
let schedule;
if (scheduleKind === "every") {
schedule = { kind: "every" as const, everyMs: Number(everySeconds) * 1000 };
} else if (scheduleKind === "cron") {
schedule = { kind: "cron" as const, expr: cronExpr };
} else {
schedule = { kind: "at" as const, atMs: job.schedule.atMs ?? Date.now() + 60000 };
}
await onUpdate(job.id, {
schedule,
message: message.trim(),
agentId: agentId.trim() || undefined,
enabled,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("detail.saveFailed"));
} finally {
setSaving(false);
}
};
return (
<div className="space-y-4">
{/* Schedule section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.schedule")}</h3>
<div className="space-y-2">
<Label>{t("create.name")}</Label>
<Input
value={job.name}
disabled
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t("create.scheduleType")}</Label>
<div className="flex gap-2">
{(["every", "cron", "at"] as const).map((kind) => (
<Button
key={kind}
variant={scheduleKind === kind ? "default" : "outline"}
size="sm"
onClick={() => !readonly && setScheduleKind(kind)}
disabled={readonly}
type="button"
>
{kind === "every" ? t("create.every") : kind === "cron" ? t("create.cron") : t("create.once")}
</Button>
))}
</div>
</div>
{scheduleKind === "every" && (
<div className="space-y-2">
<Label>{t("create.intervalSeconds")}</Label>
<Input
type="number"
min={1}
value={everySeconds}
onChange={(e) => setEverySeconds(e.target.value)}
disabled={readonly}
className="text-base md:text-sm"
/>
</div>
)}
{scheduleKind === "cron" && (
<div className="space-y-2">
<Label>{t("create.cronExpression")}</Label>
<Input
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
disabled={readonly}
placeholder="0 * * * *"
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("create.cronHint")}</p>
</div>
)}
{scheduleKind === "at" && (
<p className="text-sm text-muted-foreground">{t("create.onceDesc")}</p>
)}
</section>
{/* Message section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.messageSection")}</h3>
<div className="space-y-2">
<Label>{t("create.message")}</Label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={4}
disabled={readonly}
placeholder={t("create.messagePlaceholder")}
className="text-base md:text-sm resize-none"
/>
</div>
</section>
{/* Agent & Status section */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.agentStatus")}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("create.agentId")}</Label>
<Input
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
disabled={readonly}
placeholder={t("create.agentIdPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t("columns.enabled")}</Label>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<span className="text-sm">{job.enabled ? t("detail.enabled") : t("detail.disabled")}</span>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
disabled={readonly}
/>
</div>
</div>
</div>
{/* Info rows */}
<div className="grid grid-cols-1 gap-4 rounded-md bg-muted/50 p-4 sm:grid-cols-2">
{job.state?.nextRunAtMs && (
<InfoRow label={t("detail.infoRows.nextRun")} value={formatDate(new Date(job.state.nextRunAtMs))} />
)}
{job.state?.lastRunAtMs && (
<InfoRow label={t("detail.infoRows.lastRun")} value={formatDate(new Date(job.state.lastRunAtMs))} />
)}
{job.state?.lastStatus && (
<InfoRow label={t("detail.infoRows.lastStatus")} value={job.state.lastStatus} />
)}
<InfoRow label={t("detail.infoRows.created")} value={formatDate(new Date(job.createdAtMs))} />
<InfoRow label={t("detail.infoRows.updated")} value={formatDate(new Date(job.updatedAtMs))} />
{job.schedule.tz && (
<InfoRow label={t("detail.infoRows.timezone")} value={job.schedule.tz} />
)}
</div>
{/* Last error */}
{job.state?.lastError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<h4 className="mb-1 font-medium text-destructive">{t("detail.lastError")}</h4>
<p className="text-xs text-destructive/80">{job.state.lastError}</p>
</div>
)}
</section>
{!readonly && (
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
/>
)}
</div>
);
}
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Pagination } from "@/components/shared/pagination";
import { formatDate, formatTokens } from "@/lib/format";
import type { CronJob, CronRunLogEntry } from "../hooks/use-cron";
interface CronRunHistoryTabProps {
job: CronJob;
getRunLog: (id: string, limit?: number, offset?: number) => Promise<{ entries: CronRunLogEntry[]; total: number }>;
onRefresh: () => void;
}
function formatDuration(ms?: number): string {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export function CronRunHistoryTab({ job, getRunLog, onRefresh }: CronRunHistoryTabProps) {
const { t } = useTranslation("cron");
const [runLog, setRunLog] = useState<CronRunLogEntry[]>([]);
const [runLogTotal, setRunLogTotal] = useState(0);
const [runLogLoading, setRunLogLoading] = useState(true);
const [runLogPage, setRunLogPage] = useState(1);
const [runLogPageSize, setRunLogPageSize] = useState(10);
const isRunning = job.state?.lastStatus === "running";
const loadRunLog = useCallback(async (page?: number, pageSize?: number) => {
const p = page ?? runLogPage;
const ps = pageSize ?? runLogPageSize;
setRunLogLoading(true);
try {
const { entries, total } = await getRunLog(job.id, ps, (p - 1) * ps);
setRunLog(entries);
setRunLogTotal(total);
} finally {
setRunLogLoading(false);
}
}, [job.id, getRunLog, runLogPage, runLogPageSize]);
const runLogTotalPages = Math.ceil(runLogTotal / runLogPageSize);
useEffect(() => {
loadRunLog();
}, [loadRunLog]);
// Poll while running to detect completion
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(onRefresh, 3000);
return () => clearInterval(interval);
}, [isRunning, onRefresh]);
return (
<div>
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">{t("detail.runHistory")}</h4>
<Button variant="ghost" size="sm" onClick={() => loadRunLog()} className="text-xs">
{t("detail.refresh")}
</Button>
</div>
{runLogLoading && runLog.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : runLog.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("detail.noHistory")}</p>
) : (
<>
<div className="space-y-2">
{runLog.map((entry, i) => (
<div key={i} className="rounded-md border p-3 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{formatDate(new Date(entry.ts))}</span>
{entry.durationMs != null && entry.durationMs > 0 && (
<span className="text-xs text-muted-foreground">({formatDuration(entry.durationMs)})</span>
)}
</div>
<div className="flex items-center gap-2">
{entry.inputTokens != null && entry.inputTokens > 0 && (
<span className="text-xs text-muted-foreground">
{t("detail.inOut", {
input: formatTokens(entry.inputTokens),
output: formatTokens(entry.outputTokens ?? 0),
})}
</span>
)}
<Badge
variant={
entry.status === "ok" || entry.status === "success" ? "success" : "destructive"
}
>
{entry.status || "unknown"}
</Badge>
</div>
</div>
{entry.summary && (
<p className="mt-1 line-clamp-3 text-muted-foreground">{entry.summary}</p>
)}
{entry.error && (
<p className="mt-1 text-destructive">{entry.error}</p>
)}
</div>
))}
</div>
<Pagination
page={runLogPage}
pageSize={runLogPageSize}
total={runLogTotal}
totalPages={runLogTotalPages}
onPageChange={(p) => { setRunLogPage(p); loadRunLog(p); }}
onPageSizeChange={(s) => { setRunLogPageSize(s); setRunLogPage(1); loadRunLog(1, s); }}
pageSizes={[10, 20, 50]}
/>
</>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Clock, Play, Trash2, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { CronJob } from "./hooks/use-cron";
import { formatSchedule, CronStatusBadge } from "./cron-utils";
interface CronListRowProps {
job: CronJob;
onClick: () => void;
onRun?: () => void;
onDelete?: () => void;
}
export function CronListRow({ job, onClick, onRun, onDelete }: CronListRowProps) {
const { t } = useTranslation("cron");
const isRunning = job.state?.lastStatus === "running";
return (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-all hover:border-primary/30 hover:shadow-sm"
>
{/* Icon */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Clock className="h-4 w-4" />
</div>
{/* Name + schedule */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{job.name}</span>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${job.enabled ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Badge variant="outline" className="text-[10px] px-1 py-0">{job.schedule.kind}</Badge>
<span className="truncate">{formatSchedule(job)}</span>
</div>
</div>
{/* Status */}
<div className="hidden shrink-0 sm:block">
<CronStatusBadge status={job.state?.lastStatus} />
</div>
{/* Agent */}
<div className="hidden shrink-0 text-xs text-muted-foreground md:block md:w-28 md:truncate">
{job.agentId || t("card.defaultAgent")}
</div>
{/* Message preview */}
<div className="hidden shrink-0 text-xs text-muted-foreground/60 lg:block lg:w-40 lg:truncate">
{job.payload?.message}
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{onRun && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-primary"
disabled={isRunning}
onClick={(e) => { e.stopPropagation(); onRun(); }}
>
{isRunning
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Play className="h-3.5 w-3.5" />}
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</button>
);
}
+76 -163
View File
@@ -1,53 +1,37 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { Clock, Plus, Play, Trash2, History, RefreshCw, Loader2 } from "lucide-react";
import { Clock, Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { Pagination } from "@/components/shared/pagination";
import { SearchInput } from "@/components/shared/search-input";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useCron, type CronJob, type CronRunLogEntry } from "./hooks/use-cron";
import { useCron, type CronJob } from "./hooks/use-cron";
import { CronFormDialog } from "./cron-form-dialog";
import { CronRunLogDialog } from "./cron-run-log-dialog";
import { CronDetailPage } from "./cron-detail-page";
import { useMinLoading } from "@/hooks/use-min-loading";
import { CronListRow } from "./cron-list-row";
import { Pagination } from "@/components/shared/pagination";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useMinLoading } from "@/hooks/use-min-loading";
import { usePagination } from "@/hooks/use-pagination";
function formatSchedule(job: CronJob): string {
const s = job.schedule;
if (s.kind === "every" && s.everyMs) {
const sec = s.everyMs / 1000;
if (sec < 60) return `every ${sec}s`;
if (sec < 3600) return `every ${Math.round(sec / 60)}m`;
return `every ${Math.round(sec / 3600)}h`;
}
if (s.kind === "cron" && s.expr) return s.expr;
if (s.kind === "at" && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`;
return s.kind;
}
export function CronPage() {
const { t } = useTranslation("cron");
const { t: tc } = useTranslation("common");
const { id: detailId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { jobs, loading, refreshing, refresh, createJob, toggleJob, deleteJob, runJob, getRunLog } = useCron();
const { jobs, loading, refreshing, refresh, createJob, toggleJob, deleteJob, runJob, getRunLog, updateJob } = useCron();
const spinning = useMinLoading(refreshing);
const showSkeleton = useDeferredLoading(loading && jobs.length === 0);
const { t } = useTranslation("cron");
const { t: tc } = useTranslation("common");
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null);
const [runLogTarget, setRunLogTarget] = useState<CronJob | null>(null);
const [runLogEntries, setRunLogEntries] = useState<CronRunLogEntry[]>([]);
const [runLogLoading, setRunLogLoading] = useState(false);
const [toggleTarget, setToggleTarget] = useState<{ job: CronJob; enabled: boolean } | null>(null);
const { pageItems, pagination, setPage, setPageSize } = usePagination(jobs);
// Detail routing
const detailJob = detailId ? jobs.find((j) => j.id === detailId) : null;
if (detailJob) {
return (
@@ -60,22 +44,31 @@ export function CronPage() {
await deleteJob(id);
navigate("/cron");
}}
onUpdate={updateJob}
getRunLog={getRunLog}
onRefresh={refresh}
/>
);
}
const handleShowRunLog = async (job: CronJob) => {
setRunLogTarget(job);
setRunLogLoading(true);
try {
const { entries } = await getRunLog(job.id);
setRunLogEntries(entries);
} finally {
setRunLogLoading(false);
}
};
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return jobs;
return jobs.filter(
(j) =>
j.name.toLowerCase().includes(q) ||
(j.payload?.message ?? "").toLowerCase().includes(q),
);
}, [jobs, search]);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
// Reset page when search changes
const [prevSearch, setPrevSearch] = useState(search);
if (search !== prevSearch) {
setPrevSearch(search);
resetPage();
}
return (
<div className="p-4 sm:p-6">
@@ -94,108 +87,56 @@ export function CronPage() {
}
/>
<div className="mt-4">
{/* Toolbar */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t("searchPlaceholder")}
className="max-w-sm"
/>
</div>
<div className="mt-6">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : jobs.length === 0 ? (
<TableSkeleton />
) : filtered.length === 0 ? (
<EmptyState
icon={Clock}
title={t("emptyTitle")}
description={t("emptyDescription")}
title={search ? t("noMatchTitle") : t("emptyTitle")}
description={search ? t("noMatchDescription") : t("emptyDescription")}
action={
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("newJob")}
</Button>
!search ? (
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("newJob")}
</Button>
) : undefined
}
/>
) : (
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.enabled")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.schedule")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.message")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.agent")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{pageItems.map((job: CronJob) => (
<tr key={job.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Switch
checked={job.enabled}
onCheckedChange={(checked: boolean) => setToggleTarget({ job, enabled: checked })}
/>
</td>
<td className="px-4 py-3">
<button
type="button"
className="cursor-pointer font-medium text-primary hover:underline"
onClick={() => navigate(`/cron/${job.id}`)}
>
{job.name}
</button>
</td>
<td className="px-4 py-3">
<Badge variant="outline">{formatSchedule(job)}</Badge>
</td>
<td className="max-w-[200px] truncate px-4 py-3 text-muted-foreground">
{job.payload?.message}
</td>
<td className="px-4 py-3">
{job.agentId ? (
<Badge variant="secondary">{job.agentId}</Badge>
) : (
<span className="text-muted-foreground">{t("defaultAgent")}</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
title={job.state?.lastStatus === "running" ? t("running") : t("runNow")}
disabled={job.state?.lastStatus === "running"}
onClick={() => runJob(job.id)}
>
{job.state?.lastStatus === "running"
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Play className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
title={t("runHistory")}
onClick={() => handleShowRunLog(job)}
>
<History className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
title={t("delete.confirmLabel")}
onClick={() => setDeleteTarget(job)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
<>
<div className="mt-4 flex flex-col gap-2">
{pageItems.map((job) => (
<CronListRow
key={job.id}
job={job}
onClick={() => navigate(`/cron/${job.id}`)}
onRun={() => runJob(job.id)}
onDelete={() => setDeleteTarget(job)}
/>
))}
</div>
<div className="mt-4">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
</>
)}
</div>
@@ -205,25 +146,6 @@ export function CronPage() {
onSubmit={createJob}
/>
{toggleTarget && (
<ConfirmDialog
open
onOpenChange={() => setToggleTarget(null)}
title={toggleTarget.enabled ? t("enable.title") : t("disable.title")}
description={
toggleTarget.enabled
? t("enable.description", { name: toggleTarget.job.name })
: t("disable.description", { name: toggleTarget.job.name })
}
confirmLabel={toggleTarget.enabled ? t("enable.confirmLabel") : t("disable.confirmLabel")}
variant={toggleTarget.enabled ? "default" : "destructive"}
onConfirm={async () => {
await toggleJob(toggleTarget.job.id, toggleTarget.enabled);
setToggleTarget(null);
}}
/>
)}
{deleteTarget && (
<ConfirmDialog
open
@@ -239,15 +161,6 @@ export function CronPage() {
/>
)}
{runLogTarget && (
<CronRunLogDialog
open
onOpenChange={() => setRunLogTarget(null)}
jobName={runLogTarget.name}
entries={runLogEntries}
loading={runLogLoading}
/>
)}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
import type { CronJob } from "./hooks/use-cron";
export function formatSchedule(job: CronJob): string {
const s = job.schedule;
if (s.kind === "every" && s.everyMs) {
const sec = s.everyMs / 1000;
if (sec < 60) return `every ${sec}s`;
if (sec < 3600) return `every ${Math.round(sec / 60)}m`;
return `every ${Math.round(sec / 3600)}h`;
}
if (s.kind === "cron" && s.expr) return s.expr;
if (s.kind === "at" && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`;
return s.kind;
}
/** Status badge for cron jobs. */
export function CronStatusBadge({ status }: { status?: string }) {
if (!status) return null;
if (status === "running") {
return (
<Badge variant="outline" className="shrink-0 animate-pulse border-blue-400 text-blue-600 dark:text-blue-400">
{status}
</Badge>
);
}
if (status === "ok" || status === "success") {
return <Badge variant="success" className="shrink-0">{status}</Badge>;
}
if (status === "error" || status === "failed") {
return <Badge variant="destructive" className="shrink-0">{status}</Badge>;
}
return <Badge variant="outline" className="shrink-0">{status}</Badge>;
}
+15 -1
View File
@@ -149,5 +149,19 @@ export function useCron() {
[ws],
);
return { jobs, loading, refreshing, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog };
const updateJob = useCallback(
async (jobId: string, params: Record<string, unknown>) => {
try {
await ws.call(Methods.CRON_UPDATE, { jobId, ...params });
await invalidate();
toast.success(i18next.t("cron:toast.updated"));
} catch (err) {
toast.error(i18next.t("cron:toast.failedUpdate"), err instanceof Error ? err.message : "");
throw err;
}
},
[ws, invalidate],
);
return { jobs, loading, refreshing, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog, updateJob };
}
@@ -0,0 +1,277 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Save, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { PROVIDER_TYPES } from "@/constants/providers";
import { CLISection } from "../provider-cli-section";
import { OAuthSection } from "../provider-oauth-section";
import type { ProviderData, ProviderInput } from "@/types/provider";
interface ProviderAdvancedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: ProviderData;
onUpdate: (id: string, data: ProviderInput) => Promise<void>;
}
function deriveState(provider: ProviderData) {
const s = provider.settings as Record<string, unknown> | undefined;
return {
apiBase: provider.api_base || "",
acpBinary: provider.provider_type === "acp" ? (provider.api_base || "") : "",
acpArgs: Array.isArray(s?.args) ? (s.args as string[]).join(" ") : "",
acpIdleTTL: (s?.idle_ttl as string) || "",
acpPermMode: (s?.perm_mode as string) || "approve-all",
acpWorkDir: (s?.work_dir as string) || "",
};
}
export function ProviderAdvancedDialog({
open,
onOpenChange,
provider,
onUpdate,
}: ProviderAdvancedDialogProps) {
const { t } = useTranslation("providers");
const isACP = provider.provider_type === "acp";
const isCLI = provider.provider_type === "claude_cli";
const isOAuth = provider.provider_type === "chatgpt_oauth";
const isStandard = !isACP && !isCLI && !isOAuth;
const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
const init = deriveState(provider);
const [apiBase, setApiBase] = useState(init.apiBase);
const [acpBinary, setAcpBinary] = useState(init.acpBinary);
const [acpArgs, setAcpArgs] = useState(init.acpArgs);
const [acpIdleTTL, setAcpIdleTTL] = useState(init.acpIdleTTL);
const [acpPermMode, setAcpPermMode] = useState(init.acpPermMode);
const [acpWorkDir, setAcpWorkDir] = useState(init.acpWorkDir);
// Re-sync when dialog opens
useEffect(() => {
if (!open) return;
const s = deriveState(provider);
setApiBase(s.apiBase);
setAcpBinary(s.acpBinary);
setAcpArgs(s.acpArgs);
setAcpIdleTTL(s.acpIdleTTL);
setAcpPermMode(s.acpPermMode);
setAcpWorkDir(s.acpWorkDir);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
const data: ProviderInput = {
name: provider.name,
provider_type: provider.provider_type,
};
if (isACP) {
data.api_base = acpBinary.trim() || undefined;
const settings: Record<string, unknown> = {};
if (acpArgs.trim()) settings.args = acpArgs.trim().split(/\s+/);
if (acpIdleTTL.trim()) settings.idle_ttl = acpIdleTTL.trim();
if (acpPermMode) settings.perm_mode = acpPermMode;
if (acpWorkDir.trim()) settings.work_dir = acpWorkDir.trim();
if (Object.keys(settings).length > 0) data.settings = settings;
} else if (isStandard) {
data.api_base = apiBase.trim() || undefined;
}
await onUpdate(provider.id, data);
onOpenChange(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("form.saving"));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
{t("detail.advanced")}
</DialogTitle>
</DialogHeader>
{/* Scrollable body */}
<div className="overflow-y-auto min-h-0 -mx-4 px-4 sm:-mx-6 sm:px-6 space-y-4">
{/* Identity */}
<ConfigGroupHeader
title={t("detail.identity")}
description={t("detail.identityDesc")}
/>
<div className="space-y-2">
<Label>{t("form.name")}</Label>
<Input
value={provider.name}
disabled
className="text-base md:text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">{t("detail.nameReadonly")}</p>
</div>
{/* Connection — standard providers only */}
{isStandard && (
<>
<ConfigGroupHeader
title={t("detail.connection")}
description={t("detail.connectionDesc")}
/>
<div className="space-y-2">
<Label htmlFor="apiBase">{t("form.apiBase")}</Label>
<Input
id="apiBase"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={typeInfo?.placeholder || typeInfo?.apiBase || "https://api.example.com/v1"}
className="text-base md:text-sm"
/>
</div>
</>
)}
{/* ACP Configuration */}
{isACP && (
<>
<ConfigGroupHeader
title={t("detail.acpConfig")}
description={t("detail.acpConfigDesc")}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="acpBinary">{t("acp.binary")}</Label>
<Input
id="acpBinary"
value={acpBinary}
onChange={(e) => setAcpBinary(e.target.value)}
placeholder={t("acp.binaryPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("acp.binaryHint")}</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="acpArgs">{t("acp.args")}</Label>
<Input
id="acpArgs"
value={acpArgs}
onChange={(e) => setAcpArgs(e.target.value)}
placeholder={t("acp.argsPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("acp.argsHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="acpIdleTTL">{t("acp.idleTTL")}</Label>
<Input
id="acpIdleTTL"
value={acpIdleTTL}
onChange={(e) => setAcpIdleTTL(e.target.value)}
placeholder={t("acp.idleTTLPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("acp.idleTTLHint")}</p>
</div>
<div className="space-y-2">
<Label>{t("acp.permMode")}</Label>
<Select value={acpPermMode} onValueChange={setAcpPermMode}>
<SelectTrigger className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approve-all">{t("acp.permModeApproveAll")}</SelectItem>
<SelectItem value="approve-reads">{t("acp.permModeApproveReads")}</SelectItem>
<SelectItem value="deny-all">{t("acp.permModeDenyAll")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="acpWorkDir">{t("acp.workDir")}</Label>
<Input
id="acpWorkDir"
value={acpWorkDir}
onChange={(e) => setAcpWorkDir(e.target.value)}
placeholder={t("acp.workDirPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("acp.workDirHint")}</p>
</div>
</div>
</>
)}
{/* Claude CLI */}
{isCLI && (
<>
<ConfigGroupHeader
title={t("detail.cliConfig")}
description={t("detail.cliConfigDesc")}
/>
<CLISection open={open} />
</>
)}
{/* OAuth */}
{isOAuth && (
<>
<ConfigGroupHeader
title={t("detail.oauthConfig")}
description={t("detail.oauthConfigDesc")}
/>
<OAuthSection onSuccess={() => onOpenChange(false)} />
</>
)}
</div>
{/* Footer */}
<div className="flex flex-col gap-2 pt-4 border-t shrink-0">
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("form.cancel")}
</Button>
{!isOAuth && (
<Button onClick={handleSave} disabled={saving}>
{!saving && <Save className="h-4 w-4" />}
{saving ? t("form.saving") : t("form.save")}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useProviders } from "../hooks/use-providers";
import { ProviderHeader } from "./provider-header";
import { ProviderOverview } from "./provider-overview";
import { ProviderAdvancedDialog } from "./provider-advanced-dialog";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
interface ProviderDetailPageProps {
providerId: string;
onBack: () => void;
}
export function ProviderDetailPage({ providerId, onBack }: ProviderDetailPageProps) {
const { t } = useTranslation("providers");
const { providers, loading, updateProvider, deleteProvider } = useProviders();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const provider = providers.find((p) => p.id === providerId);
if (loading || !provider) {
return <DetailPageSkeleton tabs={0} />;
}
const displayTitle = provider.display_name || provider.name;
return (
<div>
<ProviderHeader
provider={provider}
onBack={onBack}
onAdvanced={() => setAdvancedOpen(true)}
onDelete={() => setDeleteOpen(true)}
/>
<div className="p-3 sm:p-4">
<div className="max-w-4xl">
<ProviderOverview
key={provider.id + "-" + provider.updated_at}
provider={provider}
onUpdate={updateProvider}
/>
</div>
</div>
<ProviderAdvancedDialog
key={provider.id}
open={advancedOpen}
onOpenChange={setAdvancedOpen}
provider={provider}
onUpdate={updateProvider}
/>
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("delete.title")}
description={t("delete.description", { name: displayTitle })}
confirmValue={displayTitle}
confirmLabel={t("delete.confirmLabel")}
onConfirm={async () => {
await deleteProvider(providerId);
setDeleteOpen(false);
onBack();
}}
/>
</div>
);
}
@@ -0,0 +1,85 @@
import { useTranslation } from "react-i18next";
import { ArrowLeft, Cpu, Settings, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { PROVIDER_TYPES } from "@/constants/providers";
import type { ProviderData } from "@/types/provider";
interface ProviderHeaderProps {
provider: ProviderData;
onBack: () => void;
onAdvanced: () => void;
onDelete: () => void;
}
export function ProviderHeader({ provider, onBack, onAdvanced, onDelete }: ProviderHeaderProps) {
const { t } = useTranslation("providers");
const { t: tc } = useTranslation("common");
const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
const typeLabel = typeInfo?.label ?? provider.provider_type;
const displayTitle = provider.display_name || provider.name;
return (
<TooltipProvider>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-card px-3 py-2 landscape-compact sm:px-4 sm:gap-3">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 size-9">
<ArrowLeft className="h-4 w-4" />
</Button>
{/* Provider icon */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
<Cpu className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
{/* Provider info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h2 className="truncate text-base font-semibold">{displayTitle}</h2>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
provider.enabled ? "bg-emerald-500" : "bg-muted-foreground/50",
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{provider.enabled ? tc("enabled") : tc("disabled")}
</TooltipContent>
</Tooltip>
<Badge variant="outline" className="text-[10px]">
{typeLabel}
</Badge>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span className="font-mono text-[11px]">{provider.name}</span>
<span className="text-border">·</span>
<span>{typeLabel}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onAdvanced}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,151 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { PROVIDER_TYPES } from "@/constants/providers";
import { toast } from "@/stores/use-toast-store";
import type { ProviderData, ProviderInput } from "@/types/provider";
interface ProviderOverviewProps {
provider: ProviderData;
onUpdate: (id: string, data: ProviderInput) => Promise<void>;
}
const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {
const { t } = useTranslation("providers");
const { t: tc } = useTranslation("common");
const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
const typeLabel = typeInfo?.label ?? provider.provider_type;
const showApiKey = !NO_API_KEY_TYPES.has(provider.provider_type);
// Identity
const [displayName, setDisplayName] = useState(provider.display_name || "");
// API Key
const [apiKey, setApiKey] = useState(provider.api_key || "");
// Status
const [enabled, setEnabled] = useState(provider.enabled);
// Save state
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const data: ProviderInput = {
name: provider.name,
display_name: displayName.trim() || undefined,
provider_type: provider.provider_type,
enabled,
};
// Only include api_key if changed from the masked value
if (showApiKey && apiKey && apiKey !== "***") {
data.api_key = apiKey;
}
await onUpdate(provider.id, data);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t("toast.failedUpdate"));
} finally {
setSaving(false);
}
};
const handleCopyName = () => {
navigator.clipboard.writeText(provider.name).catch(() => {});
toast.success(tc("copy"));
};
return (
<div className="space-y-4">
{/* Identity */}
<section className="space-y-4 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.identity")}</h3>
<div className="space-y-2">
<Label htmlFor="displayName">{t("form.displayName")}</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("form.displayNamePlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label>{t("detail.providerType")}</Label>
<div className="flex items-center gap-2">
<Badge variant="outline">{typeLabel}</Badge>
</div>
</div>
<div className="space-y-2">
<Label>{t("form.name")}</Label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-sm text-muted-foreground">
{provider.name}
</code>
<Button type="button" variant="outline" size="icon" className="size-9 shrink-0" onClick={handleCopyName}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</section>
{/* API Key */}
{showApiKey && (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.apiKeySection")}</h3>
<div className="space-y-2">
<Label htmlFor="apiKey">{t("form.apiKey")}</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t("form.apiKeyEditPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("form.apiKeySetHint")}</p>
</div>
</section>
)}
{/* Status */}
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<h3 className="text-sm font-medium">{t("detail.statusSection")}</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium">
{t("form.enabled")}
</Label>
<p className="text-xs text-muted-foreground">{t("detail.enabledDesc")}</p>
</div>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
</section>
<StickySaveBar
onSave={handleSave}
saving={saving}
saved={saved}
error={saveError}
/>
</div>
);
}
@@ -31,14 +31,12 @@ import { Loader2 } from "lucide-react";
interface ProviderFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: ProviderData | null; // null = create mode
onSubmit: (data: ProviderInput) => Promise<unknown>;
existingProviders?: ProviderData[];
}
export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, existingProviders = [] }: ProviderFormDialogProps) {
export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProviders = [] }: ProviderFormDialogProps) {
const { t } = useTranslation("providers");
const isEdit = !!provider;
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
@@ -56,8 +54,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
const [acpPermMode, setAcpPermMode] = useState("approve-all");
const [acpWorkDir, setAcpWorkDir] = useState("");
// Only one Claude CLI provider allowed per instance
const hasClaudeCLI = !isEdit && existingProviders.some((p) => p.provider_type === "claude_cli");
const hasClaudeCLI = existingProviders.some((p) => p.provider_type === "claude_cli");
const isOAuth = providerType === "chatgpt_oauth";
const isCLI = providerType === "claude_cli";
@@ -66,37 +63,19 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
useEffect(() => {
if (open) {
setError("");
if (provider) {
setName(provider.name);
setDisplayName(provider.display_name || "");
setProviderType(provider.provider_type);
setApiBase(provider.api_base || "");
setApiKey(provider.api_key || "");
setEnabled(provider.enabled);
// Load ACP settings from provider
if (provider.provider_type === "acp") {
const s = provider.settings as Record<string, unknown> | undefined;
setAcpBinary(provider.api_base || "");
setAcpArgs(Array.isArray(s?.args) ? (s.args as string[]).join(" ") : "");
setAcpIdleTTL((s?.idle_ttl as string) || "");
setAcpPermMode((s?.perm_mode as string) || "approve-all");
setAcpWorkDir((s?.work_dir as string) || "");
}
} else {
setName("");
setDisplayName("");
setProviderType("openai_compat");
setApiBase("");
setApiKey("");
setEnabled(true);
setAcpBinary("");
setAcpArgs("");
setAcpIdleTTL("");
setAcpPermMode("approve-all");
setAcpWorkDir("");
}
setName("");
setDisplayName("");
setProviderType("openai_compat");
setApiBase("");
setApiKey("");
setEnabled(true);
setAcpBinary("");
setAcpArgs("");
setAcpIdleTTL("");
setAcpPermMode("approve-all");
setAcpWorkDir("");
}
}, [open, provider]);
}, [open]);
const handleSubmit = async () => {
if (!name.trim() || !providerType) return;
@@ -110,7 +89,6 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
enabled,
};
// ACP: serialize fields into api_base (binary) + settings JSON
if (isACP) {
data.api_base = acpBinary.trim() || undefined;
const settings: Record<string, unknown> = {};
@@ -125,7 +103,6 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
}
}
// Only include api_key if it's a real value (not the mask)
if (apiKey && apiKey !== "***") {
data.api_key = apiKey;
}
@@ -141,44 +118,41 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] flex flex-col">
<DialogContent className="flex max-h-[85vh] flex-col">
<DialogHeader>
<DialogTitle>{isEdit ? t("form.editTitle") : t("form.createTitle")}</DialogTitle>
<DialogTitle>{t("form.createTitle")}</DialogTitle>
<DialogDescription>{t("form.configure")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{/* Provider type selector — always shown in create mode */}
{!isEdit && (
<ProviderTypeSelect
value={providerType}
hasClaudeCLI={hasClaudeCLI}
alreadyAddedLabel={t("form.alreadyAdded")}
providerTypeLabel={t("form.providerType")}
onChange={(v) => {
setProviderType(v);
const preset = PROVIDER_TYPES.find((pt) => pt.value === v);
setApiBase(preset?.apiBase || "");
if (v === "chatgpt_oauth") {
setName("openai-codex");
setDisplayName("ChatGPT (OAuth)");
} else {
if (name === "openai-codex") setName("");
if (displayName === "ChatGPT (OAuth)") setDisplayName("");
}
}}
/>
)}
<div className="-mx-4 min-h-0 overflow-y-auto px-4 py-4 sm:-mx-6 sm:px-6 space-y-4">
<ProviderTypeSelect
value={providerType}
hasClaudeCLI={hasClaudeCLI}
alreadyAddedLabel={t("form.alreadyAdded")}
providerTypeLabel={t("form.providerType")}
onChange={(v) => {
setProviderType(v);
const preset = PROVIDER_TYPES.find((pt) => pt.value === v);
setApiBase(preset?.apiBase || "");
if (v === "chatgpt_oauth") {
setName("openai-codex");
setDisplayName("ChatGPT (OAuth)");
} else {
if (name === "openai-codex") setName("");
if (displayName === "ChatGPT (OAuth)") setDisplayName("");
}
}}
/>
{isOAuth ? (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("form.nameFixed")}</Label>
<Input value="openai-codex" disabled />
<Input value="openai-codex" disabled className="text-base md:text-sm" />
</div>
<div className="space-y-2">
<Label>{t("form.displayName")}</Label>
<Input value="ChatGPT (OAuth)" disabled />
<Input value="ChatGPT (OAuth)" disabled className="text-base md:text-sm" />
</div>
</div>
<OAuthSection onSuccess={() => { queryClient.invalidateQueries({ queryKey: ["providers"] }); onOpenChange(false); }} />
@@ -193,7 +167,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
value={name}
onChange={(e) => setName(slugify(e.target.value))}
placeholder={t("form.namePlaceholder")}
disabled={isEdit}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("form.nameHint")}</p>
</div>
@@ -204,32 +178,13 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("form.displayNamePlaceholder")}
className="text-base md:text-sm"
/>
</div>
</div>
{isEdit && (
<div className="space-y-2">
<Label>{t("form.providerType")}</Label>
<Select value={providerType} onValueChange={setProviderType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPES.filter((pt) => pt.value !== "chatgpt_oauth").map((pt) => (
<SelectItem key={pt.value} value={pt.value}>
{pt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Claude CLI section */}
{isCLI && <CLISection open={open} />}
{/* ACP section */}
{isACP && (
<ACPSection
binary={acpBinary}
@@ -245,7 +200,6 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
/>
)}
{/* Standard provider fields (not shown for Claude CLI or ACP) */}
{!isCLI && !isACP && (
<>
<div className="space-y-2">
@@ -255,6 +209,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={PROVIDER_TYPES.find((pt) => pt.value === providerType)?.placeholder || PROVIDER_TYPES.find((pt) => pt.value === providerType)?.apiBase || "https://api.example.com/v1"}
className="text-base md:text-sm"
/>
</div>
@@ -265,13 +220,9 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={isEdit ? t("form.apiKeyEditPlaceholder") : t("form.apiKeyPlaceholder")}
placeholder={t("form.apiKeyPlaceholder")}
className="text-base md:text-sm"
/>
{isEdit && apiKey === "***" && (
<p className="text-xs text-muted-foreground">
{t("form.apiKeySetHint")}
</p>
)}
</div>
</>
)}
@@ -297,9 +248,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
className="gap-1"
>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{loading
? (isEdit ? t("form.saving") : t("form.creating"))
: isEdit ? t("form.save") : t("form.create")}
{loading ? t("form.creating") : t("form.create")}
</Button>
)}
</DialogFooter>
@@ -308,8 +257,6 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
);
}
// --- Provider type select dropdown ---
function ProviderTypeSelect({ value, hasClaudeCLI, alreadyAddedLabel, providerTypeLabel, onChange }: {
value: string;
hasClaudeCLI: boolean;
@@ -0,0 +1,69 @@
import { Cpu, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PROVIDER_TYPE_BADGE, ProviderApiKeyBadge } from "./provider-utils";
import type { ProviderData } from "./hooks/use-providers";
interface ProviderListRowProps {
provider: ProviderData;
onClick: () => void;
onDelete?: () => void;
}
export function ProviderListRow({ provider, onClick, onDelete }: ProviderListRowProps) {
const { t: tc } = useTranslation("common");
const displayName = provider.display_name || provider.name;
const tb = PROVIDER_TYPE_BADGE[provider.provider_type] ?? { label: provider.provider_type, variant: "outline" as const };
return (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-all hover:border-primary/30 hover:shadow-sm"
>
{/* Icon */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Cpu className="h-4 w-4" />
</div>
{/* Name + slug */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{displayName}</span>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${provider.enabled ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
</div>
{provider.display_name && (
<div className="truncate text-xs text-muted-foreground">{provider.name}</div>
)}
</div>
{/* Type badge */}
<div className="hidden shrink-0 sm:block">
<Badge variant={tb.variant} className="text-[11px]">{tb.label}</Badge>
</div>
{/* API key status */}
<div className="hidden shrink-0 md:block">
<ProviderApiKeyBadge provider={provider} />
</div>
{/* Enabled */}
<div className="hidden shrink-0 text-[11px] text-muted-foreground lg:block">
{provider.enabled ? tc("enabled") : tc("disabled")}
</div>
{/* Delete */}
{onDelete && (
<Button
variant="ghost"
size="xs"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</button>
);
}
@@ -0,0 +1,50 @@
import { Key, Link2, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PROVIDER_TYPES } from "@/constants/providers";
import type { ProviderData } from "./hooks/use-providers";
type BadgeVariant = "default" | "secondary" | "outline";
const SPECIAL_VARIANTS: Record<string, BadgeVariant> = {
anthropic_native: "default",
chatgpt_oauth: "default",
claude_cli: "outline",
acp: "outline",
};
/** Derive badge labels from PROVIDER_TYPES constant (single source of truth). */
export const PROVIDER_TYPE_BADGE: Record<string, { label: string; variant: BadgeVariant }> = Object.fromEntries(
PROVIDER_TYPES.map((pt) => [
pt.value,
{ label: pt.label.replace(/ \(.*\)$/, ""), variant: SPECIAL_VARIANTS[pt.value] ?? "secondary" },
]),
);
/** Shared API key status indicator. */
export function ProviderApiKeyBadge({ provider }: { provider: ProviderData }) {
const { t } = useTranslation("providers");
if (provider.provider_type === "chatgpt_oauth") {
return (
<span className="flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400">
<Link2 className="h-3 w-3" />{t("card.oauthLinked")}
</span>
);
}
if (provider.provider_type === "claude_cli") {
return (
<span className="flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3 w-3" />{t("card.authenticated")}
</span>
);
}
if (provider.api_key === "***") {
return (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Key className="h-3 w-3" />{t("card.apiKeySet")}
</span>
);
}
return (
<span className="text-[11px] text-muted-foreground/60">{t("apiKey.notSet")}</span>
);
}
+57 -130
View File
@@ -1,49 +1,49 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { Cpu, Plus, RefreshCw, Pencil, Trash2 } from "lucide-react";
import { Cpu, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useProviders, type ProviderData, type ProviderInput } from "./hooks/use-providers";
import { useProviders, type ProviderData } from "./hooks/use-providers";
import { ProviderFormDialog } from "./provider-form-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { ProviderListRow } from "./provider-list-row";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
const typeBadge: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
anthropic_native: { label: "Anthropic", variant: "default" },
openai_compat: { label: "OpenAI Compat", variant: "secondary" },
gemini_native: { label: "Gemini", variant: "secondary" },
openrouter: { label: "OpenRouter", variant: "secondary" },
groq: { label: "Groq", variant: "secondary" },
deepseek: { label: "DeepSeek", variant: "secondary" },
mistral: { label: "Mistral", variant: "secondary" },
xai: { label: "xAI", variant: "secondary" },
minimax_native: { label: "MiniMax", variant: "secondary" },
cohere: { label: "Cohere", variant: "secondary" },
perplexity: { label: "Perplexity", variant: "secondary" },
chatgpt_oauth: { label: "ChatGPT (OAuth)", variant: "default" },
yescale: { label: "YesScale", variant: "secondary" },
acp: { label: "ACP", variant: "outline" },
};
import { ProviderDetailPage } from "./provider-detail/provider-detail-page";
export function ProvidersPage() {
const { id: detailId } = useParams<{ id: string }>();
const navigate = useNavigate();
if (detailId) {
return (
<ProviderDetailPage
providerId={detailId}
onBack={() => navigate("/providers")}
/>
);
}
return <ProviderListView />;
}
function ProviderListView() {
const { t } = useTranslation("providers");
const { t: tc } = useTranslation("common");
const navigate = useNavigate();
const {
providers, loading, refresh,
createProvider, updateProvider, deleteProvider,
createProvider, deleteProvider,
} = useProviders();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && providers.length === 0);
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [editProvider, setEditProvider] = useState<ProviderData | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProviderData | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
@@ -57,15 +57,6 @@ export function ProvidersPage() {
useEffect(() => { resetPage(); }, [search, resetPage]);
const handleCreate = async (data: ProviderInput) => {
await createProvider(data);
};
const handleEdit = async (data: ProviderInput) => {
if (!editProvider) return;
await updateProvider(editProvider.id, data);
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleteLoading(true);
@@ -83,18 +74,13 @@ export function ProvidersPage() {
title={t("title")}
description={t("description")}
actions={
<div className="flex gap-2">
<Button size="sm" onClick={() => { setEditProvider(null); setFormOpen(true); }} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("addProvider")}
</Button>
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {tc("refresh")}
</Button>
</div>
<Button onClick={() => setFormOpen(true)} className="gap-1">
<Plus className="h-4 w-4" /> {t("addProvider")}
</Button>
}
/>
<div className="mt-4">
<div className="mt-4 flex flex-wrap items-center gap-2">
<SearchInput
value={search}
onChange={setSearch}
@@ -103,9 +89,9 @@ export function ProvidersPage() {
/>
</div>
<div className="mt-4">
<div className="mt-6">
{showSkeleton ? (
<TableSkeleton rows={5} />
<TableSkeleton />
) : filtered.length === 0 ? (
<EmptyState
icon={Cpu}
@@ -113,97 +99,38 @@ export function ProvidersPage() {
description={search ? t("noMatchDescription") : t("emptyDescription")}
/>
) : (
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.type")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.apiBase")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.apiKey")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{pageItems.map((p) => {
const tb = typeBadge[p.provider_type] ?? { label: p.provider_type, variant: "outline" as const };
return (
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<div>
<span className="font-medium">{p.display_name || p.name}</span>
{p.display_name && (
<span className="ml-1 text-xs text-muted-foreground">({p.name})</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant={tb.variant}>{tb.label}</Badge>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-[200px] truncate">
{p.api_base || "-"}
</td>
<td className="px-4 py-3">
{p.provider_type === "chatgpt_oauth" ? (
<Badge variant="outline" className="text-xs">{t("apiKey.oauthToken")}</Badge>
) : p.api_key === "***" ? (
<Badge variant="outline" className="font-mono text-xs">***</Badge>
) : (
<span className="text-xs text-muted-foreground">{t("apiKey.notSet")}</span>
)}
</td>
<td className="px-4 py-3">
<Badge variant={p.enabled ? "default" : "secondary"}>
{p.enabled ? tc("enabled") : tc("disabled")}
</Badge>
</td>
<td className="px-4 py-3 text-right">
{p.provider_type !== "chatgpt_oauth" && (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setEditProvider(p); setFormOpen(true); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(p)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
<>
<div className="mt-4 flex flex-col gap-2">
{pageItems.map((p) => (
<ProviderListRow
key={p.id}
provider={p}
onClick={() => navigate(`/providers/${p.id}`)}
onDelete={() => setDeleteTarget(p)}
/>
))}
</div>
<div className="mt-4">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
</>
)}
</div>
<ProviderFormDialog
open={formOpen}
onOpenChange={setFormOpen}
provider={editProvider}
onSubmit={editProvider ? handleEdit : handleCreate}
onSubmit={async (data) => {
await createProvider(data);
refresh();
}}
existingProviders={providers}
/>
+2 -1
View File
@@ -159,7 +159,8 @@ export function AppRoutes() {
<Route path={ROUTES.APPROVALS} element={<ApprovalsPage />} />
<Route path={ROUTES.NODES} element={<NodesPage />} />
<Route path={ROUTES.LOGS} element={<LogsPage />} />
<Route path={ROUTES.PROVIDERS} element={<ProvidersPage />} />
<Route path={ROUTES.PROVIDERS} element={<ProvidersPage key="list" />} />
<Route path={ROUTES.PROVIDER_DETAIL} element={<ProvidersPage key="detail" />} />
<Route path={ROUTES.CUSTOM_TOOLS} element={<CustomToolsPage />} />
<Route path={ROUTES.BUILTIN_TOOLS} element={<BuiltinToolsPage />} />
<Route path={ROUTES.MCP} element={<MCPPage />} />