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:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent 1f560e15d7
commit 727f345344
22 files changed
+2043

No files matched your search

+2
View File
@@ -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}>
+211
View File
@@ -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 : "",
);
},
});
}
+7
View File
@@ -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],
+108
View File
@@ -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"
}
}
+1
View File
@@ -19,6 +19,7 @@
"contacts": "Contacts",
"skills": "Skills",
"cron": "Cron",
"hooks": "Hooks",
"customTools": "Custom Tools",
"builtinTools": "Built-in Tools",
"mcpServers": "MCP Servers",
+108
View File
@@ -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"
}
}
+1
View File
@@ -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",
+108
View File
@@ -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": "超时"
}
}
+1
View File
@@ -19,6 +19,7 @@
"chat": "对话",
"config": "配置",
"cron": "定时任务",
"hooks": "Hooks",
"customTools": "自定义工具",
"logs": "日志",
"mcpServers": "MCP 服务器",
+5
View File
@@ -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,
},
+2
View File
@@ -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>
);
}
+362
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
export { HooksPage } from "./hooks-page";
+5
View File
@@ -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>} />
+75
View File
@@ -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>;