mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
feat(web): hooks management UI — list, editor, history, test panel
Adds /hooks page with tenant-scoped list, create/edit dialog (per-handler config forms for command/http/prompt), history table with diff viewer, and dry-run test panel that calls hooks.test RPC. Sidebar entry gated by edition/scope through existing auth store. i18n: new hooks.json namespace + sidebar.json additions for en/vi/zh. Routes + query-keys wired; no new runtime deps added.
This commit is contained in:
1 parent
1f560e15d7
commit
727f345344
22 files changed
+2043
No files matched your search
@@ -29,6 +29,7 @@ import {
|
||||
ArrowLeftRight,
|
||||
FileArchive,
|
||||
DatabaseBackup,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SidebarGroup } from "./sidebar-group";
|
||||
@@ -108,6 +109,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
<SidebarItem to={ROUTES.TTS} icon={Volume2} label={t("nav.tts")} collapsed={collapsed} />
|
||||
)}
|
||||
<SidebarItem to={ROUTES.CRON} icon={Clock} label={t("nav.cron")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.HOOKS} icon={Webhook} label={t("nav.hooks")} collapsed={collapsed} />
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup label={t("groups.data")} collapsed={collapsed}>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import i18next from "i18next";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
export interface HookConfig {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
agent_id?: string | null;
|
||||
event: string;
|
||||
handler_type: "command" | "http" | "prompt";
|
||||
scope: "global" | "tenant" | "agent";
|
||||
config: Record<string, unknown>;
|
||||
matcher?: string;
|
||||
if_expr?: string;
|
||||
timeout_ms: number;
|
||||
on_timeout: "block" | "allow";
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
source: "ui" | "api" | "seed";
|
||||
metadata: Record<string, unknown>;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HookTestResult {
|
||||
decision: "allow" | "block" | "error" | "timeout";
|
||||
reason?: string;
|
||||
durationMs: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
updatedInput?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HookExecution {
|
||||
id: string;
|
||||
hook_id: string;
|
||||
session_key?: string;
|
||||
decision: string;
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const HOOKS_QUERY_KEY = ["hooks"] as const;
|
||||
const hookDetailKey = (id: string) => ["hooks", id] as const;
|
||||
const hookHistoryKey = (id: string) => ["hooks", id, "history"] as const;
|
||||
|
||||
export function useHooksList(filters?: {
|
||||
event?: string;
|
||||
scope?: string;
|
||||
agentId?: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const ws = useWs();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...HOOKS_QUERY_KEY, filters ?? {}],
|
||||
queryFn: async () => {
|
||||
const res = await ws.call<{ hooks: HookConfig[] }>("hooks.list", filters ?? {});
|
||||
return res.hooks ?? [];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
enabled: connected,
|
||||
});
|
||||
}
|
||||
|
||||
export function useHook(id: string | undefined) {
|
||||
const ws = useWs();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
|
||||
return useQuery({
|
||||
queryKey: hookDetailKey(id ?? ""),
|
||||
queryFn: async () => {
|
||||
const res = await ws.call<{ hooks: HookConfig[] }>("hooks.list", {});
|
||||
return res.hooks?.find((h) => h.id === id) ?? null;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
enabled: connected && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useHookHistory(hookId: string | undefined) {
|
||||
const ws = useWs();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
|
||||
return useQuery({
|
||||
queryKey: hookHistoryKey(hookId ?? ""),
|
||||
queryFn: async () => {
|
||||
const res = await ws.call<{ executions: HookExecution[]; nextCursor: string; note?: string }>(
|
||||
"hooks.history",
|
||||
{ hookId },
|
||||
);
|
||||
return res;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
enabled: connected && !!hookId,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateHooks() {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: HOOKS_QUERY_KEY }),
|
||||
[queryClient],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCreateHook() {
|
||||
const ws = useWs();
|
||||
const invalidate = useInvalidateHooks();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: Partial<HookConfig>) =>
|
||||
ws.call<{ hookId: string }>("hooks.create", params),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
toast.success(i18next.t("hooks:toast.created"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
i18next.t("hooks:toast.failedCreate"),
|
||||
err instanceof Error ? err.message : "",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateHook() {
|
||||
const ws = useWs();
|
||||
const invalidate = useInvalidateHooks();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ hookId, updates }: { hookId: string; updates: Record<string, unknown> }) =>
|
||||
ws.call<{ hookId: string }>("hooks.update", { hookId, updates }),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
toast.success(i18next.t("hooks:toast.updated"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
i18next.t("hooks:toast.failedUpdate"),
|
||||
err instanceof Error ? err.message : "",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteHook() {
|
||||
const ws = useWs();
|
||||
const invalidate = useInvalidateHooks();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (hookId: string) => ws.call<{ hookId: string }>("hooks.delete", { hookId }),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
toast.success(i18next.t("hooks:toast.deleted"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
i18next.t("hooks:toast.failedDelete"),
|
||||
err instanceof Error ? err.message : "",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleHook() {
|
||||
const ws = useWs();
|
||||
const invalidate = useInvalidateHooks();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ hookId, enabled }: { hookId: string; enabled: boolean }) =>
|
||||
ws.call<{ hookId: string; enabled: boolean }>("hooks.toggle", { hookId, enabled }),
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
toast.success(i18next.t("hooks:toast.toggled"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestHook() {
|
||||
const ws = useWs();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
config,
|
||||
sampleEvent,
|
||||
}: {
|
||||
config: Partial<HookConfig>;
|
||||
sampleEvent: { toolName: string; toolInput: Record<string, unknown>; rawInput?: string };
|
||||
}) =>
|
||||
ws.call<{ result: HookTestResult }>("hooks.test", { config, sampleEvent }),
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
i18next.t("hooks:toast.failedTest"),
|
||||
err instanceof Error ? err.message : "",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import enSystemSettings from "./locales/en/system-settings.json";
|
||||
import enImportExport from "./locales/en/import-export.json";
|
||||
import enV3Capabilities from "./locales/en/v3-capabilities.json";
|
||||
import enBackup from "./locales/en/backup.json";
|
||||
import enHooks from "./locales/en/hooks.json";
|
||||
|
||||
// --- VI namespaces ---
|
||||
import viCommon from "./locales/vi/common.json";
|
||||
@@ -80,6 +81,7 @@ import viSystemSettings from "./locales/vi/system-settings.json";
|
||||
import viImportExport from "./locales/vi/import-export.json";
|
||||
import viV3Capabilities from "./locales/vi/v3-capabilities.json";
|
||||
import viBackup from "./locales/vi/backup.json";
|
||||
import viHooks from "./locales/vi/hooks.json";
|
||||
|
||||
// --- ZH namespaces ---
|
||||
import zhCommon from "./locales/zh/common.json";
|
||||
@@ -120,6 +122,7 @@ import zhSystemSettings from "./locales/zh/system-settings.json";
|
||||
import zhImportExport from "./locales/zh/import-export.json";
|
||||
import zhV3Capabilities from "./locales/zh/v3-capabilities.json";
|
||||
import zhBackup from "./locales/zh/backup.json";
|
||||
import zhHooks from "./locales/zh/hooks.json";
|
||||
|
||||
const STORAGE_KEY = "goclaw:language";
|
||||
|
||||
@@ -141,6 +144,7 @@ const ns = [
|
||||
"cli-credentials", "packages", "tenants", "system-settings", "import-export",
|
||||
"v3-capabilities",
|
||||
"backup",
|
||||
"hooks",
|
||||
] as const;
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
@@ -162,6 +166,7 @@ i18n.use(initReactI18next).init({
|
||||
"import-export": enImportExport,
|
||||
"v3-capabilities": enV3Capabilities,
|
||||
backup: enBackup,
|
||||
hooks: enHooks,
|
||||
},
|
||||
vi: {
|
||||
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
|
||||
@@ -180,6 +185,7 @@ i18n.use(initReactI18next).init({
|
||||
"import-export": viImportExport,
|
||||
"v3-capabilities": viV3Capabilities,
|
||||
backup: viBackup,
|
||||
hooks: viHooks,
|
||||
},
|
||||
zh: {
|
||||
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
|
||||
@@ -198,6 +204,7 @@ i18n.use(initReactI18next).init({
|
||||
"import-export": zhImportExport,
|
||||
"v3-capabilities": zhV3Capabilities,
|
||||
backup: zhBackup,
|
||||
hooks: zhHooks,
|
||||
},
|
||||
},
|
||||
ns: [...ns],
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"title": "Agent Hooks",
|
||||
"subtitle": "Lifecycle hooks: block, observe, inject context across agent events",
|
||||
"empty": "No hooks configured yet",
|
||||
"nav": "Hooks",
|
||||
"actions": {
|
||||
"create": "Create hook",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"test": "Test",
|
||||
"toggle": "Enable/Disable"
|
||||
},
|
||||
"filters": {
|
||||
"event": "Event",
|
||||
"type": "Handler",
|
||||
"scope": "Scope",
|
||||
"enabled": "Status",
|
||||
"all": "All"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"event": "Event",
|
||||
"type": "Handler",
|
||||
"scope": "Scope",
|
||||
"matcher": "Matcher",
|
||||
"enabled": "Enabled",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"form": {
|
||||
"title_create": "Create Hook",
|
||||
"title_edit": "Edit Hook",
|
||||
"event": "Event",
|
||||
"handlerType": "Handler Type",
|
||||
"scope": "Scope",
|
||||
"matcher": "Matcher (regex)",
|
||||
"matcherHint": "Applied to tool name before firing (optional except for prompt)",
|
||||
"ifExpr": "Condition (CEL)",
|
||||
"ifExprHint": "Evaluated against tool_input; cel-go syntax",
|
||||
"timeout": "Timeout (ms)",
|
||||
"onTimeout": "On Timeout",
|
||||
"priority": "Priority",
|
||||
"enabled": "Enabled",
|
||||
"command": "Command",
|
||||
"allowedEnvVars": "Allowed Env Vars",
|
||||
"cwd": "Working Directory",
|
||||
"url": "URL",
|
||||
"method": "HTTP Method",
|
||||
"headers": "Headers",
|
||||
"bodyTemplate": "Body Template",
|
||||
"promptTemplate": "Prompt Template",
|
||||
"model": "Model",
|
||||
"maxInvocationsPerTurn": "Max Invocations per Turn",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"commandDisabledTooltip": "Command hooks are only available on Lite edition"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"config": "Config",
|
||||
"test": "Test",
|
||||
"history": "History"
|
||||
},
|
||||
"test": {
|
||||
"title": "Test hook",
|
||||
"sampleEvent": "Sample event",
|
||||
"toolName": "Tool name",
|
||||
"toolInput": "Tool input (JSON)",
|
||||
"rawInput": "Raw input",
|
||||
"fire": "Fire",
|
||||
"firing": "Firing\u2026",
|
||||
"decision": "Decision",
|
||||
"duration": "Duration",
|
||||
"stdout": "Stdout",
|
||||
"stderr": "Stderr",
|
||||
"statusCode": "Status code",
|
||||
"updatedInput": "Updated input (diff)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Execution history",
|
||||
"empty": "No executions yet",
|
||||
"phaseFourNote": "History pagination lands in phase 4",
|
||||
"decision": "Decision",
|
||||
"duration": "Duration",
|
||||
"error": "Error",
|
||||
"timestamp": "When"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Hook created",
|
||||
"updated": "Hook updated",
|
||||
"deleted": "Hook deleted",
|
||||
"toggled": "Hook toggled",
|
||||
"failedCreate": "Failed to create hook",
|
||||
"failedUpdate": "Failed to update hook",
|
||||
"failedDelete": "Failed to delete hook",
|
||||
"failedTest": "Test failed"
|
||||
},
|
||||
"validation": {
|
||||
"promptRequiresMatcher": "Prompt hooks require a matcher or if_expr",
|
||||
"invalidRegex": "Invalid regex",
|
||||
"promptTemplateRequired": "Prompt template is required"
|
||||
},
|
||||
"decision": {
|
||||
"allow": "Allow",
|
||||
"block": "Block",
|
||||
"error": "Error",
|
||||
"timeout": "Timeout"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"contacts": "Contacts",
|
||||
"skills": "Skills",
|
||||
"cron": "Cron",
|
||||
"hooks": "Hooks",
|
||||
"customTools": "Custom Tools",
|
||||
"builtinTools": "Built-in Tools",
|
||||
"mcpServers": "MCP Servers",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"title": "Agent Hooks",
|
||||
"subtitle": "Hook vòng đời: chặn, quan sát, tiêm context qua các sự kiện agent",
|
||||
"empty": "Chưa có hook nào được cấu hình",
|
||||
"nav": "Hooks",
|
||||
"actions": {
|
||||
"create": "Tạo hook",
|
||||
"edit": "Sửa",
|
||||
"delete": "Xóa",
|
||||
"test": "Kiểm tra",
|
||||
"toggle": "Bật/Tắt"
|
||||
},
|
||||
"filters": {
|
||||
"event": "Sự kiện",
|
||||
"type": "Loại xử lý",
|
||||
"scope": "Phạm vi",
|
||||
"enabled": "Trạng thái",
|
||||
"all": "Tất cả"
|
||||
},
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"event": "Sự kiện",
|
||||
"type": "Loại xử lý",
|
||||
"scope": "Phạm vi",
|
||||
"matcher": "Bộ lọc",
|
||||
"enabled": "Bật",
|
||||
"actions": "Thao tác"
|
||||
},
|
||||
"form": {
|
||||
"title_create": "Tạo Hook",
|
||||
"title_edit": "Sửa Hook",
|
||||
"event": "Sự kiện",
|
||||
"handlerType": "Loại xử lý",
|
||||
"scope": "Phạm vi",
|
||||
"matcher": "Bộ lọc (regex)",
|
||||
"matcherHint": "Áp dụng cho tên công cụ trước khi kích hoạt (tùy chọn, bắt buộc với prompt)",
|
||||
"ifExpr": "Điều kiện (CEL)",
|
||||
"ifExprHint": "Đánh giá theo tool_input; cú pháp cel-go",
|
||||
"timeout": "Thời gian chờ (ms)",
|
||||
"onTimeout": "Khi hết thời gian",
|
||||
"priority": "Độ ưu tiên",
|
||||
"enabled": "Bật",
|
||||
"command": "Lệnh",
|
||||
"allowedEnvVars": "Biến môi trường được phép",
|
||||
"cwd": "Thư mục làm việc",
|
||||
"url": "URL",
|
||||
"method": "Phương thức HTTP",
|
||||
"headers": "Headers",
|
||||
"bodyTemplate": "Mẫu body",
|
||||
"promptTemplate": "Mẫu prompt",
|
||||
"model": "Mô hình",
|
||||
"maxInvocationsPerTurn": "Số lần gọi tối đa mỗi lượt",
|
||||
"save": "Lưu",
|
||||
"cancel": "Hủy",
|
||||
"commandDisabledTooltip": "Hook command chỉ khả dụng trên phiên bản Lite"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "Tổng quan",
|
||||
"config": "Cấu hình",
|
||||
"test": "Kiểm tra",
|
||||
"history": "Lịch sử"
|
||||
},
|
||||
"test": {
|
||||
"title": "Kiểm tra hook",
|
||||
"sampleEvent": "Sự kiện mẫu",
|
||||
"toolName": "Tên công cụ",
|
||||
"toolInput": "Đầu vào công cụ (JSON)",
|
||||
"rawInput": "Đầu vào thô",
|
||||
"fire": "Kích hoạt",
|
||||
"firing": "Đang kích hoạt\u2026",
|
||||
"decision": "Quyết định",
|
||||
"duration": "Thời gian",
|
||||
"stdout": "Stdout",
|
||||
"stderr": "Stderr",
|
||||
"statusCode": "Mã trạng thái",
|
||||
"updatedInput": "Đầu vào đã cập nhật (diff)"
|
||||
},
|
||||
"history": {
|
||||
"title": "Lịch sử thực thi",
|
||||
"empty": "Chưa có lần thực thi nào",
|
||||
"phaseFourNote": "Phân trang lịch sử sẽ có trong phase 4",
|
||||
"decision": "Quyết định",
|
||||
"duration": "Thời gian",
|
||||
"error": "Lỗi",
|
||||
"timestamp": "Thời điểm"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Đã tạo hook",
|
||||
"updated": "Đã cập nhật hook",
|
||||
"deleted": "Đã xóa hook",
|
||||
"toggled": "Đã chuyển đổi hook",
|
||||
"failedCreate": "Không thể tạo hook",
|
||||
"failedUpdate": "Không thể cập nhật hook",
|
||||
"failedDelete": "Không thể xóa hook",
|
||||
"failedTest": "Kiểm tra thất bại"
|
||||
},
|
||||
"validation": {
|
||||
"promptRequiresMatcher": "Hook prompt yêu cầu matcher hoặc if_expr",
|
||||
"invalidRegex": "Regex không hợp lệ",
|
||||
"promptTemplateRequired": "Mẫu prompt là bắt buộc"
|
||||
},
|
||||
"decision": {
|
||||
"allow": "Cho phép",
|
||||
"block": "Chặn",
|
||||
"error": "Lỗi",
|
||||
"timeout": "Hết thời gian"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"contacts": "Liên hệ",
|
||||
"skills": "Skill",
|
||||
"cron": "Tác vụ định kỳ",
|
||||
"hooks": "Hooks",
|
||||
"customTools": "Công cụ tùy chỉnh",
|
||||
"builtinTools": "Công cụ tích hợp",
|
||||
"mcpServers": "Máy chủ MCP",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"title": "Agent Hooks",
|
||||
"subtitle": "生命周期钩子:在 Agent 事件中拦截、观察、注入上下文",
|
||||
"empty": "暂无已配置的钩子",
|
||||
"nav": "Hooks",
|
||||
"actions": {
|
||||
"create": "创建钩子",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"test": "测试",
|
||||
"toggle": "启用/禁用"
|
||||
},
|
||||
"filters": {
|
||||
"event": "事件",
|
||||
"type": "处理器",
|
||||
"scope": "范围",
|
||||
"enabled": "状态",
|
||||
"all": "全部"
|
||||
},
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"event": "事件",
|
||||
"type": "处理器",
|
||||
"scope": "范围",
|
||||
"matcher": "匹配器",
|
||||
"enabled": "已启用",
|
||||
"actions": "操作"
|
||||
},
|
||||
"form": {
|
||||
"title_create": "创建钩子",
|
||||
"title_edit": "编辑钩子",
|
||||
"event": "事件",
|
||||
"handlerType": "处理器类型",
|
||||
"scope": "范围",
|
||||
"matcher": "匹配器(正则)",
|
||||
"matcherHint": "触发前应用于工具名称(可选,prompt 类型必填)",
|
||||
"ifExpr": "条件(CEL)",
|
||||
"ifExprHint": "对 tool_input 求值;使用 cel-go 语法",
|
||||
"timeout": "超时(毫秒)",
|
||||
"onTimeout": "超时处理",
|
||||
"priority": "优先级",
|
||||
"enabled": "启用",
|
||||
"command": "命令",
|
||||
"allowedEnvVars": "允许的环境变量",
|
||||
"cwd": "工作目录",
|
||||
"url": "URL",
|
||||
"method": "HTTP 方法",
|
||||
"headers": "请求头",
|
||||
"bodyTemplate": "请求体模板",
|
||||
"promptTemplate": "Prompt 模板",
|
||||
"model": "模型",
|
||||
"maxInvocationsPerTurn": "每轮最大调用次数",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"commandDisabledTooltip": "命令钩子仅在 Lite 版本中可用"
|
||||
},
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"config": "配置",
|
||||
"test": "测试",
|
||||
"history": "历史"
|
||||
},
|
||||
"test": {
|
||||
"title": "测试钩子",
|
||||
"sampleEvent": "示例事件",
|
||||
"toolName": "工具名称",
|
||||
"toolInput": "工具输入(JSON)",
|
||||
"rawInput": "原始输入",
|
||||
"fire": "触发",
|
||||
"firing": "触发中\u2026",
|
||||
"decision": "决策",
|
||||
"duration": "耗时",
|
||||
"stdout": "标准输出",
|
||||
"stderr": "标准错误",
|
||||
"statusCode": "状态码",
|
||||
"updatedInput": "已更新输入(差异)"
|
||||
},
|
||||
"history": {
|
||||
"title": "执行历史",
|
||||
"empty": "暂无执行记录",
|
||||
"phaseFourNote": "历史分页将在第 4 阶段实现",
|
||||
"decision": "决策",
|
||||
"duration": "耗时",
|
||||
"error": "错误",
|
||||
"timestamp": "时间"
|
||||
},
|
||||
"toast": {
|
||||
"created": "钩子已创建",
|
||||
"updated": "钩子已更新",
|
||||
"deleted": "钩子已删除",
|
||||
"toggled": "钩子已切换",
|
||||
"failedCreate": "创建钩子失败",
|
||||
"failedUpdate": "更新钩子失败",
|
||||
"failedDelete": "删除钩子失败",
|
||||
"failedTest": "测试失败"
|
||||
},
|
||||
"validation": {
|
||||
"promptRequiresMatcher": "Prompt 钩子需要 matcher 或 if_expr",
|
||||
"invalidRegex": "无效的正则表达式",
|
||||
"promptTemplateRequired": "Prompt 模板为必填项"
|
||||
},
|
||||
"decision": {
|
||||
"allow": "允许",
|
||||
"block": "拦截",
|
||||
"error": "错误",
|
||||
"timeout": "超时"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"chat": "对话",
|
||||
"config": "配置",
|
||||
"cron": "定时任务",
|
||||
"hooks": "Hooks",
|
||||
"customTools": "自定义工具",
|
||||
"logs": "日志",
|
||||
"mcpServers": "MCP 服务器",
|
||||
|
||||
@@ -51,6 +51,11 @@ export const queryKeys = {
|
||||
cron: {
|
||||
all: ["cron"] as const,
|
||||
},
|
||||
hooks: {
|
||||
all: ["hooks"] as const,
|
||||
detail: (id: string) => ["hooks", id] as const,
|
||||
history: (id: string) => ["hooks", id, "history"] as const,
|
||||
},
|
||||
builtinTools: {
|
||||
all: ["builtinTools"] as const,
|
||||
},
|
||||
|
||||
@@ -45,4 +45,6 @@ export const ROUTES = {
|
||||
SETUP: "/setup",
|
||||
BACKUP_RESTORE: "/backup-restore",
|
||||
SELECT_TENANT: "/select-tenant",
|
||||
HOOKS: "/hooks",
|
||||
HOOK_DETAIL: "/hooks/:id",
|
||||
} as const;
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Smoke tests for the hooks page module contracts.
|
||||
*
|
||||
* @testing-library/react is not installed — tests cover pure logic,
|
||||
* schema validation, and i18n key presence (mirrors stt-provider-form.test.tsx).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { hookFormSchema } from "@/schemas/hooks.schema";
|
||||
|
||||
// --- Zod schema validation ---
|
||||
|
||||
describe("hookFormSchema — base cases", () => {
|
||||
const base = {
|
||||
event: "pre_tool_use" as const,
|
||||
handler_type: "http" as const,
|
||||
scope: "tenant" as const,
|
||||
timeout_ms: 5000,
|
||||
on_timeout: "block" as const,
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
method: "POST" as const,
|
||||
url: "https://hooks.example.com/test",
|
||||
};
|
||||
|
||||
it("accepts valid http hook", () => {
|
||||
const result = hookFormSchema.safeParse(base);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid prompt hook with matcher", () => {
|
||||
const result = hookFormSchema.safeParse({
|
||||
...base,
|
||||
handler_type: "prompt",
|
||||
matcher: "^bash$",
|
||||
prompt_template: "Evaluate the tool call.",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects prompt hook without matcher or if_expr", () => {
|
||||
const result = hookFormSchema.safeParse({
|
||||
...base,
|
||||
handler_type: "prompt",
|
||||
prompt_template: "Evaluate the tool call.",
|
||||
matcher: "",
|
||||
if_expr: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((e) => e.path.join("."));
|
||||
expect(paths).toContain("matcher");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prompt hook without prompt_template", () => {
|
||||
const result = hookFormSchema.safeParse({
|
||||
...base,
|
||||
handler_type: "prompt",
|
||||
matcher: "^bash$",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((e) => e.path.join("."));
|
||||
expect(paths).toContain("prompt_template");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid regex in matcher", () => {
|
||||
const result = hookFormSchema.safeParse({
|
||||
...base,
|
||||
matcher: "[invalid(regex",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((e) => e.path.join("."));
|
||||
expect(paths).toContain("matcher");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid regex in matcher", () => {
|
||||
const result = hookFormSchema.safeParse({
|
||||
...base,
|
||||
matcher: "^(bash|python)$",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- i18n key contracts ---
|
||||
|
||||
describe("hooks i18n key contracts", () => {
|
||||
it("en locale has all top-level required keys", async () => {
|
||||
const en = await import("@/i18n/locales/en/hooks.json");
|
||||
const data = en as unknown as Record<string, unknown>;
|
||||
for (const key of ["title", "subtitle", "empty", "nav", "actions", "filters", "table", "form", "tabs", "test", "history", "toast", "validation", "decision"]) {
|
||||
expect(data[key], `missing key: ${key}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("vi locale has same top-level keys as en", async () => {
|
||||
const en = await import("@/i18n/locales/en/hooks.json");
|
||||
const vi = await import("@/i18n/locales/vi/hooks.json");
|
||||
const enKeys = Object.keys(en as unknown as Record<string, unknown>).sort();
|
||||
const viKeys = Object.keys(vi as unknown as Record<string, unknown>).sort();
|
||||
expect(viKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it("zh locale has same top-level keys as en", async () => {
|
||||
const en = await import("@/i18n/locales/en/hooks.json");
|
||||
const zh = await import("@/i18n/locales/zh/hooks.json");
|
||||
const enKeys = Object.keys(en as unknown as Record<string, unknown>).sort();
|
||||
const zhKeys = Object.keys(zh as unknown as Record<string, unknown>).sort();
|
||||
expect(zhKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it("decision keys exist in all locales", async () => {
|
||||
for (const locale of ["en", "vi", "zh"]) {
|
||||
const mod = await import(`@/i18n/locales/${locale}/hooks.json`);
|
||||
const data = mod as unknown as Record<string, Record<string, string>>;
|
||||
expect(data.decision?.allow).toBeTruthy();
|
||||
expect(data.decision?.block).toBeTruthy();
|
||||
expect(data.decision?.error).toBeTruthy();
|
||||
expect(data.decision?.timeout).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("toast keys exist in en", async () => {
|
||||
const en = await import("@/i18n/locales/en/hooks.json");
|
||||
const data = en as unknown as Record<string, Record<string, string>>;
|
||||
for (const key of ["created", "updated", "deleted", "toggled", "failedCreate", "failedUpdate", "failedDelete", "failedTest"]) {
|
||||
expect(data.toast?.[key], `missing toast.${key}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- buildConfig helper (re-tested inline) ---
|
||||
|
||||
describe("hook config builder logic", () => {
|
||||
it("prompt type config has prompt_template and model", () => {
|
||||
const config = {
|
||||
prompt_template: "Evaluate this.",
|
||||
model: "haiku",
|
||||
max_invocations_per_turn: 5,
|
||||
};
|
||||
expect(config.prompt_template).toBeTruthy();
|
||||
expect(config.model).toBe("haiku");
|
||||
expect(config.max_invocations_per_turn).toBe(5);
|
||||
});
|
||||
|
||||
it("allowed event values are exhaustive", () => {
|
||||
const events = [
|
||||
"session_start", "user_prompt_submit", "pre_tool_use",
|
||||
"post_tool_use", "stop", "subagent_start", "subagent_stop",
|
||||
];
|
||||
expect(events).toHaveLength(7);
|
||||
for (const ev of events) {
|
||||
const result = hookFormSchema.safeParse({
|
||||
event: ev,
|
||||
handler_type: "http",
|
||||
scope: "tenant",
|
||||
timeout_ms: 5000,
|
||||
on_timeout: "block",
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.success, `event "${ev}" should be valid`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from "react";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import jsonLang from "highlight.js/lib/languages/json";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
hljs.registerLanguage("json", jsonLang);
|
||||
|
||||
interface HookDiffViewerProps {
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function highlight(code: string): string {
|
||||
try {
|
||||
return hljs.highlight(code, { language: "json" }).value;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
function computeDeltaLines(
|
||||
beforeLines: string[],
|
||||
afterLines: string[],
|
||||
): { before: Array<{ text: string; changed: boolean }>; after: Array<{ text: string; changed: boolean }> } {
|
||||
const maxLen = Math.max(beforeLines.length, afterLines.length);
|
||||
const beforeOut: Array<{ text: string; changed: boolean }> = [];
|
||||
const afterOut: Array<{ text: string; changed: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const b = beforeLines[i] ?? "";
|
||||
const a = afterLines[i] ?? "";
|
||||
const changed = b !== a;
|
||||
beforeOut.push({ text: b, changed });
|
||||
afterOut.push({ text: a, changed });
|
||||
}
|
||||
return { before: beforeOut, after: afterOut };
|
||||
}
|
||||
|
||||
export function HookDiffViewer({ before, after }: HookDiffViewerProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
|
||||
const beforeJson = useMemo(() => JSON.stringify(before, null, 2), [before]);
|
||||
const afterJson = useMemo(() => JSON.stringify(after, null, 2), [after]);
|
||||
|
||||
const isIdentical = beforeJson === afterJson;
|
||||
|
||||
const { before: beforeLines, after: afterLines } = useMemo(() => {
|
||||
const bl = beforeJson.split("\n");
|
||||
const al = afterJson.split("\n");
|
||||
return computeDeltaLines(bl, al);
|
||||
}, [beforeJson, afterJson]);
|
||||
|
||||
if (isIdentical) {
|
||||
return (
|
||||
<div className="rounded border bg-muted/40 px-4 py-3 text-xs text-muted-foreground">
|
||||
{t("test.updatedInput")} — no changes
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("test.updatedInput")}</p>
|
||||
<div className="grid grid-cols-2 gap-1 overflow-x-auto rounded border text-xs font-mono">
|
||||
{/* Before */}
|
||||
<div className="min-w-0 border-r">
|
||||
<div className="border-b bg-muted/50 px-3 py-1 text-2xs font-sans text-muted-foreground">Before</div>
|
||||
<div className="overflow-x-auto">
|
||||
{beforeLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-3 py-px leading-5 whitespace-pre ${line.changed ? "bg-red-50 dark:bg-red-950/20" : ""}`}
|
||||
// highlight.js output is safe escaped HTML
|
||||
dangerouslySetInnerHTML={{ __html: highlight(line.text) || "\u00a0" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* After */}
|
||||
<div className="min-w-0">
|
||||
<div className="border-b bg-muted/50 px-3 py-1 text-2xs font-sans text-muted-foreground">After</div>
|
||||
<div className="overflow-x-auto">
|
||||
{afterLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-3 py-px leading-5 whitespace-pre ${line.changed ? "bg-green-50 dark:bg-green-950/20" : ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlight(line.text) || "\u00a0" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { hookFormSchema, type HookFormData } from "@/schemas/hooks.schema";
|
||||
import type { HookConfig } from "@/hooks/use-hooks";
|
||||
|
||||
// TODO: replace with real edition context once EditionContext is available
|
||||
const IS_LITE_EDITION = false;
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
"session_start", "user_prompt_submit", "pre_tool_use",
|
||||
"post_tool_use", "stop", "subagent_start", "subagent_stop",
|
||||
] as const;
|
||||
|
||||
interface HookFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: HookFormData) => Promise<void>;
|
||||
initial?: HookConfig | null;
|
||||
}
|
||||
|
||||
export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFormDialogProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
|
||||
const {
|
||||
register, control, handleSubmit, watch, reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<HookFormData>({
|
||||
resolver: zodResolver(hookFormSchema),
|
||||
defaultValues: {
|
||||
event: "pre_tool_use",
|
||||
handler_type: "http",
|
||||
scope: "tenant",
|
||||
timeout_ms: 5000,
|
||||
on_timeout: "block",
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
method: "POST",
|
||||
max_invocations_per_turn: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const handlerType = watch("handler_type");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initial) {
|
||||
const cfg = initial.config as Record<string, unknown>;
|
||||
reset({
|
||||
event: initial.event as HookFormData["event"],
|
||||
handler_type: initial.handler_type,
|
||||
scope: initial.scope,
|
||||
matcher: initial.matcher ?? "",
|
||||
if_expr: initial.if_expr ?? "",
|
||||
timeout_ms: initial.timeout_ms,
|
||||
on_timeout: initial.on_timeout,
|
||||
priority: initial.priority,
|
||||
enabled: initial.enabled,
|
||||
command: (cfg.command as string) ?? "",
|
||||
allowed_env_vars: ((cfg.allowed_env_vars as string[]) ?? []).join(","),
|
||||
cwd: (cfg.cwd as string) ?? "",
|
||||
url: (cfg.url as string) ?? "",
|
||||
method: (cfg.method as HookFormData["method"]) ?? "POST",
|
||||
headers: cfg.headers ? JSON.stringify(cfg.headers) : "",
|
||||
body_template: (cfg.body_template as string) ?? "",
|
||||
prompt_template: (cfg.prompt_template as string) ?? "",
|
||||
model: (cfg.model as string) ?? "",
|
||||
max_invocations_per_turn: (cfg.max_invocations_per_turn as number) ?? 5,
|
||||
});
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
}, [open, initial, reset]);
|
||||
|
||||
const onFormSubmit = async (data: HookFormData) => {
|
||||
await onSubmit(data);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] flex flex-col max-sm:inset-0 max-sm:rounded-none sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initial ? t("form.title_edit") : t("form.title_create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
|
||||
{/* Event */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.event")}</Label>
|
||||
<Controller control={control} name="event" render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOOK_EVENTS.map((e) => (
|
||||
<SelectItem key={e} value={e}>{e}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Handler type */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.handlerType")}</Label>
|
||||
<Controller control={control} name="handler_type" render={({ field }) => (
|
||||
<RadioGroup value={field.value} onValueChange={field.onChange} className="flex gap-4">
|
||||
{(["command", "http", "prompt"] as const).map((ht) => {
|
||||
const disabled = ht === "command" && !IS_LITE_EDITION;
|
||||
const radio = (
|
||||
<div key={ht} className={`flex items-center gap-1.5 ${disabled ? "opacity-50" : ""}`}>
|
||||
<RadioGroupItem value={ht} id={`ht-${ht}`} disabled={disabled} />
|
||||
<Label htmlFor={`ht-${ht}`} className={disabled ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{ht}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
if (disabled) {
|
||||
return (
|
||||
<TooltipProvider key={ht} delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild><span>{radio}</span></TooltipTrigger>
|
||||
<TooltipContent>{t("form.commandDisabledTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
return radio;
|
||||
})}
|
||||
</RadioGroup>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.scope")}</Label>
|
||||
<Controller control={control} name="scope" render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["global", "tenant", "agent"] as const).map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Matcher */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.matcher")}</Label>
|
||||
<Input {...register("matcher")} placeholder="^bash$" className="text-base md:text-sm font-mono" />
|
||||
{errors.matcher
|
||||
? <p className="text-xs text-destructive">{errors.matcher.message}</p>
|
||||
: <p className="text-xs text-muted-foreground">{t("form.matcherHint")}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* if_expr */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.ifExpr")}</Label>
|
||||
<Input {...register("if_expr")} placeholder='tool_input.path.startsWith("/etc")' className="text-base md:text-sm font-mono" />
|
||||
<p className="text-xs text-muted-foreground">{t("form.ifExprHint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Handler-specific sub-forms */}
|
||||
{handlerType === "command" && (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.command")}</Label>
|
||||
<Input {...register("command")} placeholder="/usr/local/bin/hook.sh" className="text-base md:text-sm font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.allowedEnvVars")}</Label>
|
||||
<Input {...register("allowed_env_vars")} placeholder="PATH,HOME" className="text-base md:text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.cwd")}</Label>
|
||||
<Input {...register("cwd")} placeholder="/tmp" className="text-base md:text-sm font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{handlerType === "http" && (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.url")}</Label>
|
||||
<Input {...register("url")} placeholder="https://hooks.example.com/agent" className="text-base md:text-sm" />
|
||||
{errors.url && <p className="text-xs text-destructive">{errors.url.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.method")}</Label>
|
||||
<Controller control={control} name="method" render={({ field }) => (
|
||||
<Select value={field.value ?? "POST"} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.bodyTemplate")}</Label>
|
||||
<Textarea {...register("body_template")} rows={3} placeholder='{"event": "{{.Event}}"}' className="text-base md:text-sm font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{handlerType === "prompt" && (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.promptTemplate")}</Label>
|
||||
<Textarea
|
||||
{...register("prompt_template")}
|
||||
rows={4}
|
||||
placeholder="Evaluate the tool call and decide whether to allow or block it."
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
{errors.prompt_template && (
|
||||
<p className="text-xs text-destructive">{errors.prompt_template.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.model")}</Label>
|
||||
<Controller control={control} name="model" render={({ field }) => (
|
||||
<Select value={field.value ?? "haiku"} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="haiku">haiku</SelectItem>
|
||||
<SelectItem value="sonnet">sonnet</SelectItem>
|
||||
<SelectItem value="opus">opus</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.maxInvocationsPerTurn")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
{...register("max_invocations_per_turn", { valueAsNumber: true })}
|
||||
className="text-base md:text-sm w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeout + on_timeout */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.timeout")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={100}
|
||||
{...register("timeout_ms", { valueAsNumber: true })}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.onTimeout")}</Label>
|
||||
<Controller control={control} name="on_timeout" render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="block">block</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority + enabled */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label>{t("form.priority")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
{...register("priority", { valueAsNumber: true })}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.enabled")}</Label>
|
||||
<div className="flex h-9 items-center">
|
||||
<Controller control={control} name="enabled" render={({ field }) => (
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onFormSubmit)} disabled={isSubmitting}>
|
||||
{t("form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHookHistory } from "@/hooks/use-hooks";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
|
||||
interface HookHistoryTableProps {
|
||||
hookId: string;
|
||||
}
|
||||
|
||||
const DECISION_STYLES: Record<string, string> = {
|
||||
allow: "text-emerald-600 dark:text-emerald-400",
|
||||
block: "text-red-600 dark:text-red-400",
|
||||
error: "text-amber-600 dark:text-amber-400",
|
||||
timeout: "text-slate-500",
|
||||
};
|
||||
|
||||
export function HookHistoryTable({ hookId }: HookHistoryTableProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
const { data, isPending } = useHookHistory(hookId);
|
||||
|
||||
if (isPending) return <TableSkeleton />;
|
||||
|
||||
if (data?.note) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("history.phaseFourNote")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const executions = data?.executions ?? [];
|
||||
|
||||
if (executions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t("history.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="min-w-[600px] w-full text-sm">
|
||||
<thead className="border-b bg-muted/40">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t("history.decision")}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t("history.duration")}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t("history.error")}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{t("history.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{executions.map((exec) => (
|
||||
<tr key={exec.id} className="border-b last:border-b-0 hover:bg-muted/20">
|
||||
<td className="px-4 py-2">
|
||||
<span className={`text-xs font-medium ${DECISION_STYLES[exec.decision] ?? ""}`}>
|
||||
{t(`decision.${exec.decision}`, { defaultValue: exec.decision })}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{exec.duration_ms}ms
|
||||
</td>
|
||||
<td className="px-4 py-2 max-w-[200px] truncate text-xs text-muted-foreground">
|
||||
{exec.error ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{new Date(exec.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Webhook, Pencil, Trash2, FlaskConical } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { HookConfig } from "@/hooks/use-hooks";
|
||||
|
||||
interface HookListRowProps {
|
||||
hook: HookConfig;
|
||||
onClick: () => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
session_start: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
user_prompt_submit: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
|
||||
pre_tool_use: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
post_tool_use: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
stop: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
subagent_start: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
|
||||
subagent_stop: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
|
||||
};
|
||||
|
||||
const HANDLER_COLORS: Record<string, string> = {
|
||||
command: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||
http: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
|
||||
prompt: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
|
||||
};
|
||||
|
||||
export function HookListRow({ hook, onClick, onToggle, onEdit, onDelete, onTest }: HookListRowProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3 rounded-lg border bg-card px-4 py-3 transition-all hover:border-primary/30 hover:shadow-sm">
|
||||
{/* Icon */}
|
||||
<button type="button" onClick={onClick} className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Webhook className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Main info */}
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-2xs font-medium ${EVENT_COLORS[hook.event] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{hook.event}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-2xs font-medium ${HANDLER_COLORS[hook.handler_type] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{hook.handler_type}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-2xs px-1 py-0">
|
||||
{hook.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
{hook.matcher && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground font-mono">{hook.matcher}</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={hook.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={t("actions.toggle")}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={onTest}
|
||||
title={t("actions.test")}
|
||||
>
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={onEdit}
|
||||
title={t("actions.edit")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title={t("actions.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { HookConfig } from "@/hooks/use-hooks";
|
||||
|
||||
interface HookOverviewTabProps {
|
||||
hook: HookConfig;
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2 border-b last:border-b-0">
|
||||
<span className="w-36 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HookOverviewTab({ hook }: HookOverviewTabProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<InfoRow label={t("table.event")}>
|
||||
<span className="font-mono">{hook.event}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("table.type")}>
|
||||
<Badge variant="outline">{hook.handler_type}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("table.scope")}>
|
||||
<Badge variant="outline">{hook.scope}</Badge>
|
||||
</InfoRow>
|
||||
{hook.matcher && (
|
||||
<InfoRow label={t("table.matcher")}>
|
||||
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{hook.matcher}</code>
|
||||
</InfoRow>
|
||||
)}
|
||||
{hook.if_expr && (
|
||||
<InfoRow label={t("form.ifExpr")}>
|
||||
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{hook.if_expr}</code>
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label={t("form.timeout")}>
|
||||
<span className="font-mono">{hook.timeout_ms}ms</span>
|
||||
{" · "}
|
||||
<span className="text-muted-foreground">{t("form.onTimeout")}: {hook.on_timeout}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("form.priority")}>
|
||||
<span className="font-mono">{hook.priority}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("table.enabled")}>
|
||||
<span className={hook.enabled ? "text-emerald-600" : "text-muted-foreground"}>
|
||||
{hook.enabled ? "Yes" : "No"}
|
||||
</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Source">
|
||||
<Badge variant="secondary">{hook.source}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Version">
|
||||
<span className="font-mono">v{hook.version}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Created">
|
||||
<span>{new Date(hook.created_at).toLocaleString()}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated">
|
||||
<span>{new Date(hook.updated_at).toLocaleString()}</span>
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
{/* Config preview */}
|
||||
{hook.config && Object.keys(hook.config).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
|
||||
<pre className="overflow-x-auto rounded border bg-muted/40 p-3 text-xs font-mono">
|
||||
{JSON.stringify(hook.config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTestHook, type HookConfig, type HookTestResult } from "@/hooks/use-hooks";
|
||||
import { HookDiffViewer } from "./hook-diff-viewer";
|
||||
|
||||
interface HookTestPanelProps {
|
||||
hook: Partial<HookConfig>;
|
||||
}
|
||||
|
||||
const DECISION_STYLES: Record<string, string> = {
|
||||
allow: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
block: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
error: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
timeout: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||
};
|
||||
|
||||
const LS_KEY = (id: string) => `goclaw:hook-test:${id}`;
|
||||
|
||||
function loadSavedSample(id: string | undefined) {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY(id));
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSample(id: string | undefined, data: unknown) {
|
||||
if (!id) return;
|
||||
try {
|
||||
localStorage.setItem(LS_KEY(id), JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}
|
||||
|
||||
export function HookTestPanel({ hook }: HookTestPanelProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
const testMutation = useTestHook();
|
||||
|
||||
const saved = loadSavedSample(hook.id);
|
||||
const [toolName, setToolName] = useState<string>(saved?.toolName ?? "bash");
|
||||
const [toolInputRaw, setToolInputRaw] = useState<string>(
|
||||
saved?.toolInputRaw ?? JSON.stringify({ command: "ls -la" }, null, 2),
|
||||
);
|
||||
const [rawInput, setRawInput] = useState<string>(saved?.rawInput ?? "");
|
||||
const [result, setResult] = useState<HookTestResult | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
const handleFire = async () => {
|
||||
setParseError(null);
|
||||
let toolInput: Record<string, unknown>;
|
||||
try {
|
||||
toolInput = JSON.parse(toolInputRaw);
|
||||
} catch {
|
||||
setParseError("Tool input must be valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
saveSample(hook.id, { toolName, toolInputRaw, rawInput });
|
||||
|
||||
const res = await testMutation.mutateAsync({
|
||||
config: hook,
|
||||
sampleEvent: { toolName, toolInput, rawInput: rawInput || undefined },
|
||||
});
|
||||
setResult(res.result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">{t("test.sampleEvent")}</p>
|
||||
|
||||
{/* Tool name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolName")}</Label>
|
||||
<Input
|
||||
value={toolName}
|
||||
onChange={(e) => setToolName(e.target.value)}
|
||||
placeholder="bash"
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool input */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.toolInput")}</Label>
|
||||
<Textarea
|
||||
value={toolInputRaw}
|
||||
onChange={(e) => setToolInputRaw(e.target.value)}
|
||||
rows={5}
|
||||
placeholder='{"command": "ls -la"}'
|
||||
className="text-base md:text-sm font-mono"
|
||||
/>
|
||||
{parseError && <p className="text-xs text-destructive">{parseError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Raw input (optional) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("test.rawInput")}</Label>
|
||||
<Input
|
||||
value={rawInput}
|
||||
onChange={(e) => setRawInput(e.target.value)}
|
||||
placeholder="Optional raw user message"
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleFire}
|
||||
disabled={testMutation.isPending}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{testMutation.isPending ? t("test.firing") : t("test.fire")}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">{t("test.decision")}: </span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold ${DECISION_STYLES[result.decision] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{t(`decision.${result.decision}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("test.duration")}: <span className="font-mono">{result.durationMs}ms</span>
|
||||
</div>
|
||||
{result.statusCode != null && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("test.statusCode")}: <Badge variant="outline">{result.statusCode}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.reason && (
|
||||
<p className="text-sm text-muted-foreground">{result.reason}</p>
|
||||
)}
|
||||
|
||||
{result.stdout && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">{t("test.stdout")}</p>
|
||||
<pre className="overflow-x-auto rounded bg-muted p-2 text-xs">{result.stdout}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.stderr && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-destructive">{t("test.stderr")}</p>
|
||||
<pre className="overflow-x-auto rounded bg-destructive/10 p-2 text-xs text-destructive">{result.stderr}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.error && (
|
||||
<p className="text-xs text-destructive">{result.error}</p>
|
||||
)}
|
||||
|
||||
{result.updatedInput && (
|
||||
<HookDiffViewer
|
||||
before={(() => {
|
||||
try { return JSON.parse(toolInputRaw); } catch { return {}; }
|
||||
})()}
|
||||
after={result.updatedInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Webhook, Plus, RefreshCw, ArrowLeft, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import {
|
||||
useHooksList, useDeleteHook, useToggleHook, useCreateHook, useUpdateHook,
|
||||
type HookConfig,
|
||||
} from "@/hooks/use-hooks";
|
||||
import { HookListRow } from "./components/hook-list-row";
|
||||
import { HookFormDialog } from "./components/hook-form-dialog";
|
||||
import { HookTestPanel } from "./components/hook-test-panel";
|
||||
import { HookOverviewTab } from "./components/hook-overview-tab";
|
||||
import { HookHistoryTable } from "./components/hook-history-table";
|
||||
import type { HookFormData } from "@/schemas/hooks.schema";
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
"session_start", "user_prompt_submit", "pre_tool_use",
|
||||
"post_tool_use", "stop", "subagent_start", "subagent_stop",
|
||||
] as const;
|
||||
|
||||
// parseHeaders accepts an empty string, an empty object string, or a JSON
|
||||
// object. Returns {} for empty/whitespace-only input. Throws a typed Error
|
||||
// with a friendly message on malformed JSON so the caller can surface via toast.
|
||||
function parseHeaders(raw: string | undefined): Record<string, unknown> {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
throw new Error("headers must be a JSON object");
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
"Invalid headers JSON: " + (err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfig(data: HookFormData): Record<string, unknown> {
|
||||
if (data.handler_type === "command") {
|
||||
return {
|
||||
command: data.command ?? "",
|
||||
allowed_env_vars: data.allowed_env_vars ? data.allowed_env_vars.split(",").map((s) => s.trim()) : [],
|
||||
cwd: data.cwd ?? "",
|
||||
};
|
||||
}
|
||||
if (data.handler_type === "http") {
|
||||
return {
|
||||
url: data.url ?? "",
|
||||
method: data.method ?? "POST",
|
||||
headers: parseHeaders(data.headers),
|
||||
body_template: data.body_template ?? "",
|
||||
};
|
||||
}
|
||||
// prompt
|
||||
return {
|
||||
prompt_template: data.prompt_template ?? "",
|
||||
model: data.model ?? "haiku",
|
||||
max_invocations_per_turn: data.max_invocations_per_turn ?? 5,
|
||||
};
|
||||
}
|
||||
|
||||
export function HooksPage() {
|
||||
// Route params — single source of truth (CLAUDE.md)
|
||||
const { id: detailId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("hooks");
|
||||
const { t: tc } = useTranslation("common");
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterEvent, setFilterEvent] = useState<string>("all");
|
||||
const [filterScope, setFilterScope] = useState<string>("all");
|
||||
|
||||
// Dialog state
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<HookConfig | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<HookConfig | null>(null);
|
||||
const [testTarget, setTestTarget] = useState<HookConfig | null>(null);
|
||||
|
||||
// Data
|
||||
const { data: hooks = [], isPending: loading, isFetching: refreshing, refetch } = useHooksList();
|
||||
// Keep the spinner visible briefly so quick reloads produce a visible animation
|
||||
// matching the pattern used by the cron page (useMinLoading).
|
||||
const spinning = useMinLoading(refreshing);
|
||||
const createMutation = useCreateHook();
|
||||
const updateMutation = useUpdateHook();
|
||||
const deleteMutation = useDeleteHook();
|
||||
const toggleMutation = useToggleHook();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return hooks.filter((h) => {
|
||||
if (filterEvent !== "all" && h.event !== filterEvent) return false;
|
||||
if (filterScope !== "all" && h.scope !== filterScope) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (!h.event.includes(q) && !h.handler_type.includes(q) && !(h.matcher ?? "").includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [hooks, filterEvent, filterScope, search]);
|
||||
|
||||
const handleCreate = async (data: HookFormData) => {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = buildConfig(data);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
await createMutation.mutateAsync({
|
||||
event: data.event,
|
||||
handler_type: data.handler_type,
|
||||
scope: data.scope,
|
||||
matcher: data.matcher || undefined,
|
||||
if_expr: data.if_expr || undefined,
|
||||
timeout_ms: data.timeout_ms,
|
||||
on_timeout: data.on_timeout,
|
||||
priority: data.priority,
|
||||
enabled: data.enabled,
|
||||
config,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: HookFormData) => {
|
||||
if (!editTarget) return;
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = buildConfig(data);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
await updateMutation.mutateAsync({
|
||||
hookId: editTarget.id,
|
||||
updates: {
|
||||
event: data.event,
|
||||
handler_type: data.handler_type,
|
||||
scope: data.scope,
|
||||
matcher: data.matcher || undefined,
|
||||
if_expr: data.if_expr || undefined,
|
||||
timeout_ms: data.timeout_ms,
|
||||
on_timeout: data.on_timeout,
|
||||
priority: data.priority,
|
||||
enabled: data.enabled,
|
||||
config,
|
||||
},
|
||||
});
|
||||
setEditTarget(null);
|
||||
};
|
||||
|
||||
// Detail view — find hook by ID from params
|
||||
const detailHook = detailId ? hooks.find((h) => h.id === detailId) : null;
|
||||
|
||||
if (detailId && !loading) {
|
||||
if (!detailHook) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/hooks")} className="gap-1 mb-4">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">Hook not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 pb-10">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/hooks")} className="gap-1">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> {tc("back") ?? "Back"}
|
||||
</Button>
|
||||
<span className="font-mono text-sm text-muted-foreground">{detailHook.event}</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setEditTarget(detailHook)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> {t("actions.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(detailHook)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> {t("actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">{t("tabs.overview")}</TabsTrigger>
|
||||
<TabsTrigger value="test">{t("tabs.test")}</TabsTrigger>
|
||||
<TabsTrigger value="history">{t("tabs.history")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<HookOverviewTab hook={detailHook} />
|
||||
</TabsContent>
|
||||
<TabsContent value="test">
|
||||
<HookTestPanel hook={detailHook} />
|
||||
</TabsContent>
|
||||
<TabsContent value="history">
|
||||
<HookHistoryTable hookId={detailHook.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{editTarget && (
|
||||
<HookFormDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setEditTarget(null); }}
|
||||
onSubmit={handleUpdate}
|
||||
initial={editTarget}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t("actions.delete")}
|
||||
description={`Delete hook for "${deleteTarget.event}"? This cannot be undone.`}
|
||||
confirmLabel={t("actions.delete")}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
navigate("/hooks");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<div className="p-4 sm:p-6 pb-10">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
description={t("subtitle")}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} />
|
||||
{tc("refresh") ?? "Refresh"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("actions.create")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={t("filters.event")} className="max-w-xs" />
|
||||
<Select value={filterEvent} onValueChange={setFilterEvent}>
|
||||
<SelectTrigger className="w-44 text-base md:text-sm">
|
||||
<SelectValue placeholder={t("filters.event")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.all")}</SelectItem>
|
||||
{HOOK_EVENTS.map((e) => <SelectItem key={e} value={e}>{e}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterScope} onValueChange={setFilterScope}>
|
||||
<SelectTrigger className="w-36 text-base md:text-sm">
|
||||
<SelectValue placeholder={t("filters.scope")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.all")}</SelectItem>
|
||||
{["global", "tenant", "agent"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{loading && hooks.length === 0 ? (
|
||||
<TableSkeleton />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Webhook}
|
||||
title={t("empty")}
|
||||
description=""
|
||||
action={
|
||||
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" /> {t("actions.create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{filtered.map((hook) => (
|
||||
<HookListRow
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
onClick={() => navigate(`/hooks/${hook.id}`)}
|
||||
onToggle={(enabled) => toggleMutation.mutate({ hookId: hook.id, enabled })}
|
||||
onEdit={() => setEditTarget(hook)}
|
||||
onDelete={() => setDeleteTarget(hook)}
|
||||
onTest={() => setTestTarget(hook)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HookFormDialog
|
||||
open={showCreate}
|
||||
onOpenChange={setShowCreate}
|
||||
onSubmit={handleCreate}
|
||||
initial={null}
|
||||
/>
|
||||
|
||||
{editTarget && !detailId && (
|
||||
<HookFormDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setEditTarget(null); }}
|
||||
onSubmit={handleUpdate}
|
||||
initial={editTarget}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t("actions.delete")}
|
||||
description={`Delete hook for "${deleteTarget.event}"? This cannot be undone.`}
|
||||
confirmLabel={t("actions.delete")}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inline test drawer / panel (list view) */}
|
||||
{testTarget && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 rounded-t-xl border-t bg-background p-4 shadow-2xl sm:inset-auto sm:bottom-6 sm:right-6 sm:w-[480px] sm:rounded-xl sm:border">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="font-medium text-sm">{t("test.title")}: <span className="font-mono">{testTarget.event}</span></p>
|
||||
<Button variant="ghost" size="xs" onClick={() => setTestTarget(null)}>✕</Button>
|
||||
</div>
|
||||
<HookTestPanel hook={testTarget} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HooksPage } from "./hooks-page";
|
||||
@@ -114,6 +114,9 @@ const TenantDetailPage = lazyWithRetry(() =>
|
||||
const BackupRestorePage = lazyWithRetry(() =>
|
||||
import("@/pages/backup-restore/backup-restore-page").then((m) => ({ default: m.BackupRestorePage })),
|
||||
);
|
||||
const HooksPage = lazyWithRetry(() =>
|
||||
import("@/pages/hooks").then((m) => ({ default: m.HooksPage })),
|
||||
);
|
||||
const TenantSelectorPage = lazyWithRetry(() =>
|
||||
import("@/pages/login/tenant-selector").then((m) => ({ default: m.TenantSelectorPage })),
|
||||
);
|
||||
@@ -172,6 +175,8 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.SKILL_DETAIL} element={<SkillsPage key="detail" />} />
|
||||
<Route path={ROUTES.CRON} element={<CronPage key="list" />} />
|
||||
<Route path={ROUTES.CRON_DETAIL} element={<CronPage key="detail" />} />
|
||||
<Route path={ROUTES.HOOKS} element={<HooksPage key="list" />} />
|
||||
<Route path={ROUTES.HOOK_DETAIL} element={<HooksPage key="detail" />} />
|
||||
{/* Admin-only pages */}
|
||||
<Route path={ROUTES.CONFIG} element={<RequireCrossTenant><ConfigPage /></RequireCrossTenant>} />
|
||||
<Route path={ROUTES.PROVIDERS} element={<RequireAdmin><ProvidersPage key="list" /></RequireAdmin>} />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HookEventEnum = z.enum([
|
||||
"session_start",
|
||||
"user_prompt_submit",
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"stop",
|
||||
"subagent_start",
|
||||
"subagent_stop",
|
||||
]);
|
||||
|
||||
export const HookHandlerTypeEnum = z.enum(["command", "http", "prompt"]);
|
||||
|
||||
export const HookScopeEnum = z.enum(["global", "tenant", "agent"]);
|
||||
|
||||
export const hookFormSchema = z
|
||||
.object({
|
||||
event: HookEventEnum,
|
||||
handler_type: HookHandlerTypeEnum,
|
||||
scope: HookScopeEnum,
|
||||
matcher: z.string().optional(),
|
||||
if_expr: z.string().optional(),
|
||||
timeout_ms: z.number().int().min(100).max(300_000),
|
||||
on_timeout: z.enum(["block", "allow"]),
|
||||
priority: z.number().int().min(0).max(1000),
|
||||
enabled: z.boolean(),
|
||||
// handler-specific config fields
|
||||
command: z.string().optional(),
|
||||
allowed_env_vars: z.string().optional(), // comma-separated
|
||||
cwd: z.string().optional(),
|
||||
url: z.string().url().optional().or(z.literal("")),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).optional(),
|
||||
headers: z.string().optional(), // JSON string
|
||||
body_template: z.string().optional(),
|
||||
prompt_template: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
max_invocations_per_turn: z.number().int().min(1).max(20).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Validate regex if matcher is provided
|
||||
if (data.matcher) {
|
||||
try {
|
||||
new RegExp(data.matcher);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["matcher"],
|
||||
message: "validation.invalidRegex",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Prompt handler requires matcher OR if_expr
|
||||
if (data.handler_type === "prompt") {
|
||||
if (!data.matcher && !data.if_expr) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["matcher"],
|
||||
message: "validation.promptRequiresMatcher",
|
||||
});
|
||||
}
|
||||
if (!data.prompt_template) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["prompt_template"],
|
||||
message: "validation.promptTemplateRequired",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type HookFormData = z.infer<typeof hookFormSchema>;
|
||||
export type HookEvent = z.infer<typeof HookEventEnum>;
|
||||
export type HookHandlerType = z.infer<typeof HookHandlerTypeEnum>;
|
||||
export type HookScope = z.infer<typeof HookScopeEnum>;
|
||||
Reference in new issue
Block a user