From f90a5f0974b3c4b4ef4ee407dbed94c5de751c9e Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 20 Mar 2026 13:04:52 +0700 Subject: [PATCH] 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 --- .../components/shared/view-mode-toggle.tsx | 46 +++ ui/web/src/i18n/locales/en/channels.json | 15 + ui/web/src/i18n/locales/en/common.json | 4 +- ui/web/src/i18n/locales/en/cron.json | 46 ++- ui/web/src/i18n/locales/en/providers.json | 29 ++ ui/web/src/i18n/locales/vi/channels.json | 15 + ui/web/src/i18n/locales/vi/common.json | 4 +- ui/web/src/i18n/locales/vi/cron.json | 46 ++- ui/web/src/i18n/locales/vi/providers.json | 29 ++ ui/web/src/i18n/locales/zh/channels.json | 15 + ui/web/src/i18n/locales/zh/common.json | 4 +- ui/web/src/i18n/locales/zh/cron.json | 46 ++- ui/web/src/i18n/locales/zh/providers.json | 29 ++ ui/web/src/lib/constants.ts | 1 + .../channel-advanced-dialog.tsx | 209 +++++++++++++ .../channel-detail/channel-config-tab.tsx | 132 -------- .../channel-detail/channel-detail-page.tsx | 175 +++++------ .../channel-detail/channel-general-tab.tsx | 165 ++++++---- .../channel-detail/channel-header.tsx | 92 ++++++ .../src/pages/channels/channel-list-row.tsx | 93 ++++++ ui/web/src/pages/channels/channels-page.tsx | 133 ++------ ui/web/src/pages/cron/cron-detail-page.tsx | 288 +----------------- .../cron/cron-detail/cron-advanced-dialog.tsx | 199 ++++++++++++ .../cron/cron-detail/cron-detail-page.tsx | 130 ++++++++ .../pages/cron/cron-detail/cron-header.tsx | 123 ++++++++ .../cron/cron-detail/cron-overview-tab.tsx | 225 ++++++++++++++ .../cron/cron-detail/cron-run-history-tab.tsx | 126 ++++++++ ui/web/src/pages/cron/cron-list-row.tsx | 85 ++++++ ui/web/src/pages/cron/cron-page.tsx | 239 +++++---------- ui/web/src/pages/cron/cron-utils.tsx | 34 +++ ui/web/src/pages/cron/hooks/use-cron.ts | 16 +- .../provider-advanced-dialog.tsx | 277 +++++++++++++++++ .../provider-detail/provider-detail-page.tsx | 71 +++++ .../provider-detail/provider-header.tsx | 85 ++++++ .../provider-detail/provider-overview.tsx | 151 +++++++++ .../pages/providers/provider-form-dialog.tsx | 139 +++------ .../src/pages/providers/provider-list-row.tsx | 69 +++++ ui/web/src/pages/providers/provider-utils.tsx | 50 +++ ui/web/src/pages/providers/providers-page.tsx | 187 ++++-------- ui/web/src/routes.tsx | 3 +- 40 files changed, 2748 insertions(+), 1077 deletions(-) create mode 100644 ui/web/src/components/shared/view-mode-toggle.tsx create mode 100644 ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx delete mode 100644 ui/web/src/pages/channels/channel-detail/channel-config-tab.tsx create mode 100644 ui/web/src/pages/channels/channel-detail/channel-header.tsx create mode 100644 ui/web/src/pages/channels/channel-list-row.tsx create mode 100644 ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx create mode 100644 ui/web/src/pages/cron/cron-detail/cron-detail-page.tsx create mode 100644 ui/web/src/pages/cron/cron-detail/cron-header.tsx create mode 100644 ui/web/src/pages/cron/cron-detail/cron-overview-tab.tsx create mode 100644 ui/web/src/pages/cron/cron-detail/cron-run-history-tab.tsx create mode 100644 ui/web/src/pages/cron/cron-list-row.tsx create mode 100644 ui/web/src/pages/cron/cron-utils.tsx create mode 100644 ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx create mode 100644 ui/web/src/pages/providers/provider-detail/provider-detail-page.tsx create mode 100644 ui/web/src/pages/providers/provider-detail/provider-header.tsx create mode 100644 ui/web/src/pages/providers/provider-detail/provider-overview.tsx create mode 100644 ui/web/src/pages/providers/provider-list-row.tsx create mode 100644 ui/web/src/pages/providers/provider-utils.tsx diff --git a/ui/web/src/components/shared/view-mode-toggle.tsx b/ui/web/src/components/shared/view-mode-toggle.tsx new file mode 100644 index 00000000..9b55d8f6 --- /dev/null +++ b/ui/web/src/components/shared/view-mode-toggle.tsx @@ -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 ( + +
+ + + + + {t("viewCard")} + + + + + + {t("viewList")} + +
+
+ ); +} diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 0cd0d07a..18bbad69 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -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", diff --git a/ui/web/src/i18n/locales/en/common.json b/ui/web/src/i18n/locales/en/common.json index 05af8f9d..f78d3192 100644 --- a/ui/web/src/i18n/locales/en/common.json +++ b/ui/web/src/i18n/locales/en/common.json @@ -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" } diff --git a/ui/web/src/i18n/locales/en/cron.json b/ui/web/src/i18n/locales/en/cron.json index e745d40d..4f349405 100644 --- a/ui/web/src/i18n/locales/en/cron.json +++ b/ui/web/src/i18n/locales/en/cron.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/en/providers.json b/ui/web/src/i18n/locales/en/providers.json index b960fd89..3662129c 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 42eedcb7..c11a362e 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/common.json b/ui/web/src/i18n/locales/vi/common.json index 329183e7..797f29ae 100644 --- a/ui/web/src/i18n/locales/vi/common.json +++ b/ui/web/src/i18n/locales/vi/common.json @@ -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" } diff --git a/ui/web/src/i18n/locales/vi/cron.json b/ui/web/src/i18n/locales/vi/cron.json index 7f4b7ccd..1f910eb7 100644 --- a/ui/web/src/i18n/locales/vi/cron.json +++ b/ui/web/src/i18n/locales/vi/cron.json @@ -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" } } diff --git a/ui/web/src/i18n/locales/vi/providers.json b/ui/web/src/i18n/locales/vi/providers.json index be935a9c..0fecc59a 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 360dbb6a..80ed29ef 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -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类型", diff --git a/ui/web/src/i18n/locales/zh/common.json b/ui/web/src/i18n/locales/zh/common.json index e7426a67..c2cb9076 100644 --- a/ui/web/src/i18n/locales/zh/common.json +++ b/ui/web/src/i18n/locales/zh/common.json @@ -116,5 +116,7 @@ "failedToLoadImage": "图片加载失败", "deleteFolder": "删除文件夹", "deleteFile": "删除文件", - "loadMore": "加载更多..." + "loadMore": "加载更多...", + "viewCard": "卡片视图", + "viewList": "列表视图" } diff --git a/ui/web/src/i18n/locales/zh/cron.json b/ui/web/src/i18n/locales/zh/cron.json index 890e7d50..34a1ddea 100644 --- a/ui/web/src/i18n/locales/zh/cron.json +++ b/ui/web/src/i18n/locales/zh/cron.json @@ -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": "更新任务失败" } } diff --git a/ui/web/src/i18n/locales/zh/providers.json b/ui/web/src/i18n/locales/zh/providers.json index bb45c52f..c543fe0e 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -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}} 已添加", diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 5d91901f..6ebb4bd0 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -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", diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx new file mode 100644 index 00000000..121cd691 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -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) => Promise; +} + +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 { + const config = (instance.config ?? {}) as Record; + const { groups: _groups, ...rest } = config as Record & { 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>(() => deriveInitialValues(instance)); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(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; + 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 ( + + + + + + {t("detail.advancedTitle")} + + + + {/* Scrollable body */} +
+ {!hasAnyGroup && ( +

{t("detail.config.noSchema")}

+ )} + + {groups.network.length > 0 && ( + <> + + + + )} + + {groups.limits.length > 0 && ( + <> + + + + )} + + {groups.streaming.length > 0 && ( + <> + + + + )} + + {groups.behavior.length > 0 && ( + <> + + + + )} + + {groups.access.length > 0 && ( + <> + + + + )} +
+ + {/* Footer */} +
+ {saveError && ( +

{saveError}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-config-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-config-tab.tsx deleted file mode 100644 index 0325eb28..00000000 --- a/ui/web/src/pages/channels/channel-detail/channel-config-tab.tsx +++ /dev/null @@ -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) => Promise; -} - -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 & { groups?: unknown }; - - const [values, setValues] = useState>(restConfig); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(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 | 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 ( -
-

- {t("detail.config.noSchema")} -

-
- ); - } - - // 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://${webhookPath}`; - - const [copied, setCopied] = useState(false); - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(webhookPath); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [webhookPath]); - - return ( -
- - - - - {showWebhookUrl && ( -
- -
-

{t("detail.config.webhookUrlLabel")}

-
- - {webhookUrl} - - -
-
-
- )} - - {saveError && ( -
- - {saveError} -
- )} -
- {saved && ( - - {t("detail.config.saved")} - - )} - -
-
- ); -} diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 1e81f9ab..dc5d29c2 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -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 ; @@ -48,100 +49,92 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps const isTelegram = instance.channel_type === "telegram"; + const handleDelete = () => { + if (onDelete) { + setDeleteOpen(true); + } + }; + return ( -
- {/* Header */} -
- -
- -
-
-
-

- {instance.display_name || instance.name} -

- - {instance.enabled ? t("enabled") : t("disabled")} - - {status && ( - - {status.running ? t("status.running") : t("status.stopped")} - - )} -
-
- {instance.display_name && ( - <> - {instance.name} - | - - )} - - {channelTypeLabels[instance.channel_type] || instance.channel_type} - - | - {t("detail.agent", { name: agentName })} -
-
-
+
+ setAdvancedOpen(true)} + onDelete={handleDelete} + /> - {/* Tabs */} -
- - - {t("detail.tabs.general")} - {t("detail.tabs.credentials")} - {t("detail.tabs.config")} - {isTelegram && {t("detail.tabs.groups")}} - {t("detail.tabs.managers")} - +
+
+ + + {t("detail.tabs.general")} + {t("detail.tabs.credentials")} + {isTelegram && {t("detail.tabs.groups")}} + {t("detail.tabs.managers")} + - - - - - - - - - - - - - {isTelegram && ( - - + - )} - - - - + + + + + {isTelegram && ( + + + + )} + + + + + +
+ + + + {onDelete && ( + { + onDelete({ id: instance.id, name: instance.display_name || instance.name }); + setDeleteOpen(false); + }} + /> + )}
); } diff --git a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx index 975e639a..10088ec8 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx @@ -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; + const initialPolicyValues = Object.fromEntries( + ESSENTIAL_CONFIG_KEYS + .filter((k) => existingConfig[k] !== undefined) + .map((k) => [k, existingConfig[k]]), + ); + const [policyValues, setPolicyValues] = useState>(initialPolicyValues); + const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(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 ( -
- {/* Read-only fields */} -
- - -

{t("detail.general.nameHint")}

-
+
+ {/* Identity section */} +
+

{t("detail.general.identity")}

-
- - -
- - {/* Editable fields */} -
- - setDisplayName(e.target.value)} - placeholder={t("detail.general.displayNamePlaceholder")} - /> -
- -
- - -
- -
- - -
- - {/* Save */} - {saveError && ( -
- - {saveError} +
+ + +

{t("detail.general.nameHint")}

+ +
+ + +
+ +
+ + setDisplayName(e.target.value)} + placeholder={t("detail.general.displayNamePlaceholder")} + className="text-base md:text-sm" + /> +
+ +
+ + +
+ +
+ + +
+
+ + {/* Policies section — only shown if this channel type has essential config fields */} + {essentialFields.length > 0 && ( +
+

{t("detail.policies")}

+ +
)} -
- {saved && ( - - {t("detail.general.saved")} - - )} - -
+ +
); } diff --git a/ui/web/src/pages/channels/channel-detail/channel-header.tsx b/ui/web/src/pages/channels/channel-detail/channel-header.tsx new file mode 100644 index 00000000..3be129d8 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-header.tsx @@ -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 ( + +
+ + + {/* Channel icon */} +
+ +
+ + {/* Channel info */} +
+
+

{displayTitle}

+ + + + + + {instance.enabled ? t("enabled") : t("disabled")} + + + {status && ( + + {status.running ? t("status.running") : t("status.stopped")} + + )} +
+
+ {instance.name} + · + {typeLabel} + · + {t("detail.agent", { name: agentName })} +
+
+ + + + {!instance.is_default && ( + + )} +
+
+ ); +} diff --git a/ui/web/src/pages/channels/channel-list-row.tsx b/ui/web/src/pages/channels/channel-list-row.tsx new file mode 100644 index 00000000..40706e7c --- /dev/null +++ b/ui/web/src/pages/channels/channel-list-row.tsx @@ -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 ( + + )} + {onDelete && !instance.is_default && ( + + )} +
+ + ); +} diff --git a/ui/web/src/pages/channels/channels-page.tsx b/ui/web/src/pages/channels/channels-page.tsx index 5b915ba6..381b1e92 100644 --- a/ui/web/src/pages/channels/channels-page.tsx +++ b/ui/web/src/pages/channels/channels-page.tsx @@ -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 navigate("/channels")} />; + return ( + navigate("/channels")} + onDelete={async ({ id }) => { + await deleteInstance(id); + navigate("/channels"); + }} + /> + ); } const handleCreate = async (data: ChannelInstanceInput) => { @@ -125,7 +133,7 @@ export function ChannelsPage() { } /> -
+
{showSkeleton ? ( - + ) : instances.length === 0 ? ( ) : ( -
- - - - - - - - - - - - - {instances.map((inst) => { - const status = getStatus(inst.name); - return ( - navigate(`/channels/${inst.id}`)} - > - - - - - - - - ); - })} - -
{t("columns.name")}{t("columns.type")}{t("columns.agent")}{t("columns.status")}{t("columns.enabled")}{t("columns.actions")}
-
- -
- {inst.display_name || inst.name} - {inst.display_name && ( - ({inst.name}) - )} -
-
-
- - {channelTypeLabels[inst.channel_type] || inst.channel_type} - - - {getAgentName(inst.agent_id)} - - {status ? ( -
- - - {status.running ? t("status.running") : t("status.stopped")} - -
- ) : ( - - - )} -
- - {inst.enabled ? t("enabled") : t("disabled")} - - -
- {channelsWithAuth.has(inst.channel_type) && ( - - )} - - {!inst.is_default && ( - - )} -
-
+ <> +
+ {instances.map((inst) => ( + navigate(`/channels/${inst.id}`)} + onAuth={channelsWithAuth.has(inst.channel_type) ? () => setQrTarget(inst) : undefined} + onDelete={!inst.is_default ? () => setDeleteTarget(inst) : undefined} + /> + ))} +
{ setPageSize(size); setPage(1); }} /> -
+ )}
@@ -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); }} /> diff --git a/ui/web/src/pages/cron/cron-detail-page.tsx b/ui/web/src/pages/cron/cron-detail-page.tsx index 1b88f7d2..3e63b123 100644 --- a/ui/web/src/pages/cron/cron-detail-page.tsx +++ b/ui/web/src/pages/cron/cron-detail-page.tsx @@ -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; - onToggle: (id: string, enabled: boolean) => Promise; - onDelete: (id: string) => Promise; - 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([]); - 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 ( -
- {/* Header */} -
-
- -
-

- {job.name} - - {job.enabled ? t("detail.enabled") : t("detail.disabled")} - -

-
- {job.schedule.kind} - {job.agentId && {job.agentId}} - {job.state?.lastStatus && ( - - {job.state.lastStatus === "running" ? t("detail.runningStatus") : job.state.lastStatus} - - )} -
-
-
-
- - - -
-
- - {/* Content */} -
-
- {/* Job Info */} -
- - {job.schedule.tz && } - {job.state?.nextRunAtMs && ( - - )} - {job.state?.lastRunAtMs && ( - - )} - - - {job.deleteAfterRun && } -
- - {/* Payload */} -
-

{t("detail.payload")}

-
-
- {job.payload?.message || t("detail.empty")} -
-
- {job.payload?.deliver && {t("detail.deliverDirect")}} - {job.payload?.channel && {t("detail.channel", { name: job.payload.channel })}} - {job.payload?.to && {t("detail.to", { name: job.payload.to })}} -
-
-
- - {/* Last Error */} - {job.state?.lastError && ( -
-

{t("detail.lastError")}

-

{job.state.lastError}

-
- )} - - {/* Run History */} -
-
-

{t("detail.runHistory")}

- -
- {runLogLoading && runLog.length === 0 ? ( -
-
-
- ) : runLog.length === 0 ? ( -

{t("detail.noHistory")}

- ) : ( - <> -
- {runLog.map((entry, i) => ( -
-
-
- {formatDate(new Date(entry.ts))} - {entry.durationMs != null && entry.durationMs > 0 && ( - ({formatDuration(entry.durationMs)}) - )} -
-
- {(entry.inputTokens != null && entry.inputTokens > 0) && ( - - {t("detail.inOut", { input: formatTokens(entry.inputTokens), output: formatTokens(entry.outputTokens ?? 0) })} - - )} - - {entry.status || "unknown"} - -
-
- {entry.summary && ( -

{entry.summary}

- )} - {entry.error && ( -

{entry.error}

- )} -
- ))} -
- { setRunLogPage(p); loadRunLog(p); }} - onPageSizeChange={(s) => { setRunLogPageSize(s); setRunLogPage(1); loadRunLog(1, s); }} - pageSizes={[10, 20, 50]} - /> - - )} -
-
-
- - { - await onDelete(job.id); - setConfirmDelete(false); - }} - /> - - { - await onToggle(job.id, !job.enabled); - setConfirmToggle(false); - }} - /> -
- ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} -
{value}
-
- ); -} +// Re-export from refactored location — cron-page.tsx imports this path +export { CronDetailPage } from "./cron-detail/cron-detail-page"; diff --git a/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx b/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx new file mode 100644 index 00000000..87ba44a0 --- /dev/null +++ b/ui/web/src/pages/cron/cron-detail/cron-advanced-dialog.tsx @@ -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) => Promise; +} + +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).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(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 ( + + + + + + {t("detail.advanced")} + + + + {/* Scrollable body */} +
+ + {/* Scheduling */} + +
+ + +

{t("detail.timezoneDesc")}

+
+ + {/* Delivery */} + +
+
+

{t("detail.deliverToChannel")}

+ +
+ + {deliver && ( + <> +
+ + setChannel(e.target.value)} + placeholder={t("detail.channelPlaceholder")} + className="text-base md:text-sm" + /> +
+
+ + setTo(e.target.value)} + placeholder={t("detail.toPlaceholder")} + className="text-base md:text-sm" + /> +
+ + )} + +
+
+

{t("detail.wakeHeartbeat")}

+

{t("detail.wakeHeartbeatDesc")}

+
+ +
+
+ + {/* Lifecycle */} + +
+
+

{t("detail.deleteAfterRun")}

+

{t("detail.deleteAfterRunDesc")}

+
+ +
+ +
+ + {/* Footer */} +
+ {saveError && ( +

{saveError}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/cron/cron-detail/cron-detail-page.tsx b/ui/web/src/pages/cron/cron-detail/cron-detail-page.tsx new file mode 100644 index 00000000..63a47268 --- /dev/null +++ b/ui/web/src/pages/cron/cron-detail/cron-detail-page.tsx @@ -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; + onToggle: (id: string, enabled: boolean) => Promise; + onDelete: (id: string) => Promise; + onUpdate?: (id: string, params: Record) => Promise; + 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 ( +
+ setAdvancedOpen(true)} + onToggle={() => setConfirmToggle(true)} + onDelete={() => setConfirmDelete(true)} + /> + +
+
+ + + {t("detail.tabs.overview")} + {t("detail.tabs.history")} + + + + + + + + + + +
+
+ + + + { + await onDelete(job.id); + setConfirmDelete(false); + }} + /> + + { + await onToggle(job.id, !job.enabled); + setConfirmToggle(false); + }} + /> +
+ ); +} diff --git a/ui/web/src/pages/cron/cron-detail/cron-header.tsx b/ui/web/src/pages/cron/cron-detail/cron-header.tsx new file mode 100644 index 00000000..83f7fa6b --- /dev/null +++ b/ui/web/src/pages/cron/cron-detail/cron-header.tsx @@ -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 ( + +
+ + + {/* Clock icon */} +
+ +
+ + {/* Job info */} +
+
+

{job.name}

+ + {job.enabled ? t("detail.enabled") : t("detail.disabled")} + + + + + + + {job.enabled ? t("detail.enabled") : t("detail.disabled")} + + +
+
+ {job.schedule.kind} + · + {formatSchedule(job)} + {job.agentId && ( + <> + · + {job.agentId} + + )} +
+
+ + {/* Run Now */} + + + {/* Toggle */} + + + {/* Advanced */} + + + {/* Delete */} + +
+
+ ); +} diff --git a/ui/web/src/pages/cron/cron-detail/cron-overview-tab.tsx b/ui/web/src/pages/cron/cron-detail/cron-overview-tab.tsx new file mode 100644 index 00000000..84301104 --- /dev/null +++ b/ui/web/src/pages/cron/cron-detail/cron-overview-tab.tsx @@ -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) => Promise; +} + +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 ( +
+ {label} +
{value}
+
+ ); +} + +export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) { + const { t } = useTranslation("cron"); + const readonly = !onUpdate; + + const [scheduleKind, setScheduleKind] = useState(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(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 ( +
+ {/* Schedule section */} +
+

{t("detail.schedule")}

+ +
+ + +
+ +
+ +
+ {(["every", "cron", "at"] as const).map((kind) => ( + + ))} +
+
+ + {scheduleKind === "every" && ( +
+ + setEverySeconds(e.target.value)} + disabled={readonly} + className="text-base md:text-sm" + /> +
+ )} + + {scheduleKind === "cron" && ( +
+ + setCronExpr(e.target.value)} + disabled={readonly} + placeholder="0 * * * *" + className="text-base md:text-sm" + /> +

{t("create.cronHint")}

+
+ )} + + {scheduleKind === "at" && ( +

{t("create.onceDesc")}

+ )} +
+ + {/* Message section */} +
+

{t("detail.messageSection")}

+
+ +