mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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类型",
|
||||
|
||||
@@ -116,5 +116,7 @@
|
||||
"failedToLoadImage": "图片加载失败",
|
||||
"deleteFolder": "删除文件夹",
|
||||
"deleteFile": "删除文件",
|
||||
"loadMore": "加载更多..."
|
||||
"loadMore": "加载更多...",
|
||||
"viewCard": "卡片视图",
|
||||
"viewList": "列表视图"
|
||||
}
|
||||
@@ -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": "更新任务失败"
|
||||
}
|
||||
}
|
||||
@@ -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}} 已添加",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in new issue
Block a user