This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit c9c310d8f67f011d389119111cd7b8975d18faf9 Author: Logic <[email protected]> AuthorDate: Fri Aug 14 08:45:06 2026 +0800 feat(ai): add schedule management UI --- .../startup/AgentGatewayMigrationTest.java | 18 ++- web-app/src/app/administrative-app-routes.test.tsx | 27 +++- web-app/src/app/app-routes.tsx | 13 ++ .../src/app/refine/refine-resource-registry.tsx | 6 + web-app/src/app/route-registry.test.ts | 10 ++ web-app/src/app/route-registry.ts | 3 + web-app/src/assets/i18n/en-us.json | 52 +++++++ web-app/src/assets/i18n/ja-jp.json | 52 +++++++ web-app/src/assets/i18n/pt-br.json | 52 +++++++ web-app/src/assets/i18n/zh-cn.json | 52 +++++++ web-app/src/assets/i18n/zh-tw.json | 52 +++++++ web-app/src/core/http/api-message.ts | 10 +- .../ai-workspace/api/agent-schedule-api.test.ts | 135 +++++++++++++++++ .../ai-workspace/api/agent-schedule-api.ts | 122 +++++++++++++++ .../ai-workspace/api/agent-schedule-schema.ts | 49 ++++++ .../components/agent-schedule-editor.tsx | 137 +++++++++++++++++ .../components/agent-schedule-table.tsx | 138 +++++++++++++++++ .../components/agent-schedule-transcript.tsx | 55 +++++++ .../components/agent-schedule-view.module.css | 76 ++++++++++ .../components/agent-schedule-view.tsx | 65 ++++++++ .../components/agent-workspace-session-pane.tsx | 17 ++- .../components/agent-workspace-view.module.css | 4 +- .../components/agent-workspace-view.test.tsx | 6 +- .../components/agent-workspace-view.tsx | 11 +- .../use-agent-schedule-controller.test.tsx | 104 +++++++++++++ .../controller/use-agent-schedule-controller.ts | 36 +++++ .../controller/use-agent-schedule-editor.ts | 107 +++++++++++++ .../controller/use-agent-schedule-list.ts | 58 +++++++ .../controller/use-agent-schedule-transcript.ts | 82 ++++++++++ web-app/src/features/ai-workspace/index.ts | 1 + .../ai-workspace/model/agent-schedule-contract.ts | 52 +++++++ .../model/agent-schedule-view-model.ts | 60 ++++++++ .../pages/agent-schedule-page.test.tsx | 167 +++++++++++++++++++++ .../ai-workspace/pages/agent-schedule-page.tsx | 43 ++++++ .../ai-workspace/pages/agent-workspace-page.tsx | 4 + web-app/src/shared/navigation/app-paths.ts | 1 + 36 files changed, 1862 insertions(+), 15 deletions(-) diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java index b5fd73f524..9da9c127e8 100644 --- a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java +++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java @@ -31,7 +31,7 @@ import java.sql.Statement; import java.util.Locale; import org.junit.jupiter.api.Test; -/** Upgrade contract for the non-destructive Agent Gateway schema. */ +/** Contract for the Agent Gateway schema included in the 2.0 foundation. */ class AgentGatewayMigrationTest { private static final String[] AGENT_TABLES = { @@ -39,7 +39,7 @@ class AgentGatewayMigrationTest { "hzb_agent_run", "hzb_agent_tool_call", "hzb_agent_transcript_entry", - "hzb_agent_scheduled_command", + "hzb_agent_schedule", "hzb_alert_analysis_policy" }; @@ -54,7 +54,7 @@ class AgentGatewayMigrationTest { statement.execute("CREATE TABLE hzb_sop_schedule (id BIGINT PRIMARY KEY)"); statement.execute("INSERT INTO hzb_ai_conversation(id) VALUES (1)"); - for (String sql : migration("h2").split(";")) { + for (String sql : gatewaySchema(migration("h2")).split(";")) { if (!sql.isBlank()) { statement.execute(sql); } @@ -66,6 +66,8 @@ class AgentGatewayMigrationTest { assertTrue(tableExists(statement, table), table); } assertTrue(columnExists(statement, "hzb_agent_run", "target_context_json")); + assertTrue(columnExists(statement, "hzb_agent_run", "entry_type")); + assertTrue(columnExists(statement, "hzb_agent_session", "origin_entry_type")); } } } @@ -114,10 +116,18 @@ class AgentGatewayMigrationTest { } private String migration(String database) throws IOException { - String path = "/db/migration/" + database + "/V206__add_agent_gateway.sql"; + String path = "/db/migration/" + database + "/V200__create_entity_foundation.sql"; try (var input = getClass().getResourceAsStream(path)) { assertNotNull(input, path); return new String(input.readAllBytes(), StandardCharsets.UTF_8); } } + + private String gatewaySchema(String migration) { + int start = migration.indexOf("CREATE TABLE IF NOT EXISTS hzb_agent_session"); + int end = migration.indexOf("ALTER TABLE hzb_config ALTER COLUMN content CLOB", start); + assertTrue(start >= 0, "agent session schema"); + assertTrue(end > start, "agent gateway schema boundary"); + return migration.substring(start, end); + } } diff --git a/web-app/src/app/administrative-app-routes.test.tsx b/web-app/src/app/administrative-app-routes.test.tsx index f8fe189657..c86499b47c 100644 --- a/web-app/src/app/administrative-app-routes.test.tsx +++ b/web-app/src/app/administrative-app-routes.test.tsx @@ -18,6 +18,7 @@ import { i18n, initializeI18n, loadLocale } from '@/core/i18n/i18n'; import { appRoutes } from './app-routes'; const probes = vi.hoisted(() => ({ + agentScheduleApi: vi.fn(), deploymentApi: vi.fn(), deploymentLoader: vi.fn(), pluginApi: vi.fn(), @@ -26,6 +27,19 @@ const probes = vi.hoisted(() => ({ tokenLoader: vi.fn() })); +vi.mock('@/features/ai-workspace', async () => { + const React = await import('react'); + return { + AgentWorkspacePage: () => React.createElement('div', { 'data-testid': 'ai-workspace-page' }), + AgentSchedulePage: () => { + React.useEffect(() => { + probes.agentScheduleApi(); + }, []); + return React.createElement('div', { 'data-testid': 'agent-schedules-page' }); + } + }; +}); + vi.mock('@/features/setup', () => ({ SetupPage: () => null })); @@ -97,6 +111,7 @@ describe('actual administrative app routes', () => { probes.pluginLoader.mockClear(); probes.tokenApi.mockClear(); probes.tokenLoader.mockClear(); + probes.agentScheduleApi.mockClear(); }); it.each([ @@ -105,7 +120,9 @@ describe('actual administrative app routes', () => { ['/settings/plugins', 'USER'], ['/settings/plugins', 'GUEST'], ['/settings/deployment', 'USER'], - ['/settings/deployment', 'GUEST'] + ['/settings/deployment', 'GUEST'], + ['/ai/schedules', 'USER'], + ['/ai/schedules', 'GUEST'] ])('does not mount the feature loader or API at %s for %s', async (path, role) => { renderAppRoute(path, role); @@ -119,6 +136,7 @@ describe('actual administrative app routes', () => { expect(probes.pluginApi).not.toHaveBeenCalled(); expect(probes.deploymentLoader).not.toHaveBeenCalled(); expect(probes.deploymentApi).not.toHaveBeenCalled(); + expect(probes.agentScheduleApi).not.toHaveBeenCalled(); }); it('admits ADMIN to the Token loader and page API', async () => { @@ -145,6 +163,13 @@ describe('actual administrative app routes', () => { await waitFor(() => expect(probes.deploymentApi).toHaveBeenCalledOnce()); }); + it('admits ADMIN to the guarded Agent schedule page API', async () => { + renderAppRoute('/ai/schedules', 'ADMIN'); + + expect(await screen.findByTestId('agent-schedules-page')).toBeInTheDocument(); + await waitFor(() => expect(probes.agentScheduleApi).toHaveBeenCalledOnce()); + }); + it('converges the legacy Plugin path on the guarded canonical route', async () => { renderAppRoute('/setting/plugin', 'USER'); diff --git a/web-app/src/app/app-routes.tsx b/web-app/src/app/app-routes.tsx index ea61a8fb7e..e59a0d787b 100644 --- a/web-app/src/app/app-routes.tsx +++ b/web-app/src/app/app-routes.tsx @@ -131,6 +131,19 @@ export const appRoutes: RouteObject[] = [ } ] }, + { + ...getAppRouteIdentity('ai-schedules'), + element: <ResourceRouteAccess routeId="ai-schedules" />, + children: [ + { + index: true, + lazy: async () => { + const { AgentSchedulePage } = await import('@/features/ai-workspace'); + return { Component: AgentSchedulePage }; + } + } + ] + }, { ...getAppRouteIdentity('monitors'), lazy: async () => { diff --git a/web-app/src/app/refine/refine-resource-registry.tsx b/web-app/src/app/refine/refine-resource-registry.tsx index 5d7d659393..62bbb4a2b9 100644 --- a/web-app/src/app/refine/refine-resource-registry.tsx +++ b/web-app/src/app/refine/refine-resource-registry.tsx @@ -146,6 +146,12 @@ const staticRefineResources: ResourceProps[] = [ order: 6, timePolicy: 'none' }), + routedNavigationResource('ai-schedules', { + navigation: false, + icon: <ScheduleOutlined />, + order: 0, + timePolicy: 'none' + }), routedNavigationResource('monitors', { parent: 'shell-basic-monitoring', icon: <RadarChartOutlined />, diff --git a/web-app/src/app/route-registry.test.ts b/web-app/src/app/route-registry.test.ts index 2019929d3b..18db8debb7 100644 --- a/web-app/src/app/route-registry.test.ts +++ b/web-app/src/app/route-registry.test.ts @@ -248,6 +248,15 @@ describe('route registry', () => { expectTypeOf<'ai-workspace'>().toMatchTypeOf<AppResourceRouteId>(); }); + it('keeps system Agent schedules inside the administrative AI workspace', () => { + expect(getAppRoute('ai-schedules')).toMatchObject({ + id: 'ai-schedules', + path: '/ai/schedules', + kind: 'page', + resource: { labelKey: 'aiSchedules.title', requiredRoles: ['ADMIN'] } + }); + }); + it('registers existing resource definition editing centrally', () => { expect(getAppRoute('entity-definition')).toMatchObject({ id: 'entity-definition', @@ -340,6 +349,7 @@ describe('route registry', () => { expect(getAppRoute('labels').resource).toMatchObject({ requiredRoles: ['ADMIN', 'USER', 'GUEST'] }); expect(routeRegistry.flatMap(route => (route.resource?.requiredRoles ? [route.id] : []))).toEqual([ 'ai-workspace', + 'ai-schedules', 'instrumentation', 'tokens', 'plugins', diff --git a/web-app/src/app/route-registry.ts b/web-app/src/app/route-registry.ts index 22b4923bbc..abcee16d6c 100644 --- a/web-app/src/app/route-registry.ts +++ b/web-app/src/app/route-registry.ts @@ -50,6 +50,9 @@ export const appRouteCatalog = { 'ai-workspace': pageRoute('ai-workspace', applicationRoutePaths.aiWorkspace, { resource: { labelKey: 'menu.aiWorkspace', requiredRoles: ['ADMIN', 'USER'] } }), + 'ai-schedules': pageRoute('ai-schedules', applicationRoutePaths.aiSchedules, { + resource: { labelKey: 'aiSchedules.title', requiredRoles: ['ADMIN'] } + }), monitors: pageRoute('monitors', monitorRoutePaths.list, { resource: { labelKey: 'menu.monitors' } }), diff --git a/web-app/src/assets/i18n/en-us.json b/web-app/src/assets/i18n/en-us.json index 2b57aca3cb..8e41949fd4 100644 --- a/web-app/src/assets/i18n/en-us.json +++ b/web-app/src/assets/i18n/en-us.json @@ -3320,5 +3320,57 @@ "apiKeyHint": "Leave blank to keep the existing key.", "save": "Save provider" } + }, + "aiSchedules": { + "title": "Scheduled investigations", + "description": "Run a fixed investigation on a schedule and deliver the result through configured notification receivers.", + "workspaceAction": "Scheduled investigations", + "create": "Create schedule", + "never": "Not scheduled", + "receiverCount": "{{count}} receivers", + "columns": { + "schedule": "Schedule", + "instruction": "Investigation instruction", + "delivery": "Delivery", + "nextRun": "Next run", + "enabled": "Enabled", + "actions": "Actions" + }, + "actions": { + "runNow": "Run now", + "transcript": "Run history", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Delete this schedule?" + }, + "states": { + "loading": "Loading scheduled investigations…", + "empty": "No scheduled investigations yet.", + "emptyDescription": "Create a schedule to run the same investigation and notify the same receivers automatically.", + "unavailable": "Scheduled investigations are unavailable.", + "mutationFailed": "The schedule change could not be confirmed. Refresh before trying again." + }, + "editor": { + "createTitle": "Create scheduled investigation", + "editTitle": "Edit scheduled investigation", + "name": "Name", + "instruction": "Investigation instruction", + "cron": "Schedule expression", + "cronHint": "Six-field Spring cron; seconds must be 0", + "receivers": "Notification receivers", + "template": "Notification template", + "templateHint": "A custom template requires receivers of one type", + "defaultTemplate": "Use each receiver type's default template", + "enabled": "Enable after saving", + "save": "Save schedule" + }, + "transcript": { + "title": "Run history · {{name}}", + "loading": "Loading run history…", + "empty": "This schedule has not produced a transcript yet.", + "unavailable": "Run history is unavailable.", + "loadEarlier": "Load earlier runs", + "noText": "No text content" + } } } diff --git a/web-app/src/assets/i18n/ja-jp.json b/web-app/src/assets/i18n/ja-jp.json index 85a565ea33..a3f16a2376 100644 --- a/web-app/src/assets/i18n/ja-jp.json +++ b/web-app/src/assets/i18n/ja-jp.json @@ -3314,5 +3314,57 @@ "apiKeyHint": "空欄にすると既存のキーを維持します。", "save": "プロバイダーを保存" } + }, + "aiSchedules": { + "title": "定期調査", + "description": "決まった調査をスケジュール実行し、設定済みの通知先へ結果を送信します。", + "workspaceAction": "定期調査", + "create": "定期調査を作成", + "never": "未設定", + "receiverCount": "{{count}} 件の通知先", + "columns": { + "schedule": "スケジュール", + "instruction": "調査指示", + "delivery": "通知", + "nextRun": "次回実行", + "enabled": "有効", + "actions": "操作" + }, + "actions": { + "runNow": "今すぐ実行", + "transcript": "実行履歴", + "edit": "編集", + "delete": "削除", + "deleteConfirm": "この定期調査を削除しますか?" + }, + "states": { + "loading": "定期調査を読み込んでいます…", + "empty": "定期調査はまだありません。", + "emptyDescription": "同じ調査を定期実行し、指定した通知先へ送信できます。", + "unavailable": "定期調査を利用できません。", + "mutationFailed": "変更を確認できませんでした。更新してから再試行してください。" + }, + "editor": { + "createTitle": "定期調査を作成", + "editTitle": "定期調査を編集", + "name": "名前", + "instruction": "調査指示", + "cron": "実行スケジュール", + "cronHint": "6 フィールドの Spring cron。秒は 0 にしてください", + "receivers": "通知先", + "template": "通知テンプレート", + "templateHint": "カスタムテンプレートでは通知先の種別を統一してください", + "defaultTemplate": "通知先種別ごとの既定テンプレートを使用", + "enabled": "保存後に有効化", + "save": "定期調査を保存" + }, + "transcript": { + "title": "実行履歴 · {{name}}", + "loading": "実行履歴を読み込んでいます…", + "empty": "この定期調査には実行履歴がありません。", + "unavailable": "実行履歴を利用できません。", + "loadEarlier": "以前の履歴を読み込む", + "noText": "テキスト内容なし" + } } } diff --git a/web-app/src/assets/i18n/pt-br.json b/web-app/src/assets/i18n/pt-br.json index 53018f76c6..90f74309fd 100644 --- a/web-app/src/assets/i18n/pt-br.json +++ b/web-app/src/assets/i18n/pt-br.json @@ -3327,5 +3327,57 @@ "apiKeyHint": "Deixe em branco para manter a chave existente.", "save": "Salvar provedor" } + }, + "aiSchedules": { + "title": "Investigações agendadas", + "description": "Execute uma investigação fixa por agenda e envie o resultado aos destinatários configurados.", + "workspaceAction": "Investigações agendadas", + "create": "Criar agendamento", + "never": "Não agendado", + "receiverCount": "{{count}} destinatários", + "columns": { + "schedule": "Agendamento", + "instruction": "Instrução da investigação", + "delivery": "Entrega", + "nextRun": "Próxima execução", + "enabled": "Ativo", + "actions": "Ações" + }, + "actions": { + "runNow": "Executar agora", + "transcript": "Histórico", + "edit": "Editar", + "delete": "Excluir", + "deleteConfirm": "Excluir este agendamento?" + }, + "states": { + "loading": "Carregando investigações agendadas…", + "empty": "Ainda não há investigações agendadas.", + "emptyDescription": "Crie um agendamento para repetir a investigação e notificar os mesmos destinatários.", + "unavailable": "As investigações agendadas estão indisponíveis.", + "mutationFailed": "Não foi possível confirmar a alteração. Atualize antes de tentar novamente." + }, + "editor": { + "createTitle": "Criar investigação agendada", + "editTitle": "Editar investigação agendada", + "name": "Nome", + "instruction": "Instrução da investigação", + "cron": "Expressão de agendamento", + "cronHint": "Cron Spring de seis campos; os segundos devem ser 0", + "receivers": "Destinatários da notificação", + "template": "Modelo de notificação", + "templateHint": "Um modelo personalizado requer destinatários do mesmo tipo", + "defaultTemplate": "Usar o modelo padrão de cada tipo de destinatário", + "enabled": "Ativar após salvar", + "save": "Salvar agendamento" + }, + "transcript": { + "title": "Histórico · {{name}}", + "loading": "Carregando histórico…", + "empty": "Este agendamento ainda não gerou um histórico.", + "unavailable": "O histórico está indisponível.", + "loadEarlier": "Carregar histórico anterior", + "noText": "Sem conteúdo de texto" + } } } diff --git a/web-app/src/assets/i18n/zh-cn.json b/web-app/src/assets/i18n/zh-cn.json index 4d3ee6a484..dc1499a6d2 100644 --- a/web-app/src/assets/i18n/zh-cn.json +++ b/web-app/src/assets/i18n/zh-cn.json @@ -3295,5 +3295,57 @@ "apiKeyHint": "留空可保留现有密钥。", "save": "保存提供方" } + }, + "aiSchedules": { + "title": "定时调查", + "description": "按计划执行固定调查,并通过已配置的通知接收人发送结果。", + "workspaceAction": "定时调查", + "create": "新建定时调查", + "never": "未安排", + "receiverCount": "{{count}} 个接收人", + "columns": { + "schedule": "任务", + "instruction": "调查指令", + "delivery": "通知", + "nextRun": "下次执行", + "enabled": "启用", + "actions": "操作" + }, + "actions": { + "runNow": "立即执行", + "transcript": "执行记录", + "edit": "编辑", + "delete": "删除", + "deleteConfirm": "删除这个定时调查吗?" + }, + "states": { + "loading": "正在加载定时调查…", + "empty": "还没有定时调查。", + "emptyDescription": "创建后可按计划重复执行同一调查,并通知指定接收人。", + "unavailable": "定时调查暂不可用。", + "mutationFailed": "无法确认本次变更,请刷新后重试。" + }, + "editor": { + "createTitle": "新建定时调查", + "editTitle": "编辑定时调查", + "name": "名称", + "instruction": "调查指令", + "cron": "执行计划", + "cronHint": "使用六段 Spring cron,秒必须为 0", + "receivers": "通知接收人", + "template": "通知模板", + "templateHint": "自定义模板要求所有接收人类型一致", + "defaultTemplate": "使用各接收人类型的默认模板", + "enabled": "保存后启用", + "save": "保存定时调查" + }, + "transcript": { + "title": "执行记录 · {{name}}", + "loading": "正在加载执行记录…", + "empty": "这个定时调查还没有执行记录。", + "unavailable": "执行记录暂不可用。", + "loadEarlier": "加载更早记录", + "noText": "没有文本内容" + } } } diff --git a/web-app/src/assets/i18n/zh-tw.json b/web-app/src/assets/i18n/zh-tw.json index fe416046bd..152dbc66b6 100644 --- a/web-app/src/assets/i18n/zh-tw.json +++ b/web-app/src/assets/i18n/zh-tw.json @@ -3295,5 +3295,57 @@ "apiKeyHint": "留空可保留現有金鑰。", "save": "儲存提供方" } + }, + "aiSchedules": { + "title": "定時調查", + "description": "依排程執行固定調查,並透過已設定的通知接收者傳送結果。", + "workspaceAction": "定時調查", + "create": "新增定時調查", + "never": "未排程", + "receiverCount": "{{count}} 位接收者", + "columns": { + "schedule": "任務", + "instruction": "調查指令", + "delivery": "通知", + "nextRun": "下次執行", + "enabled": "啟用", + "actions": "操作" + }, + "actions": { + "runNow": "立即執行", + "transcript": "執行記錄", + "edit": "編輯", + "delete": "刪除", + "deleteConfirm": "刪除這個定時調查嗎?" + }, + "states": { + "loading": "正在載入定時調查…", + "empty": "尚無定時調查。", + "emptyDescription": "建立後可依排程重複執行相同調查,並通知指定接收者。", + "unavailable": "定時調查暫時無法使用。", + "mutationFailed": "無法確認本次變更,請重新整理後再試。" + }, + "editor": { + "createTitle": "新增定時調查", + "editTitle": "編輯定時調查", + "name": "名稱", + "instruction": "調查指令", + "cron": "執行排程", + "cronHint": "使用六段 Spring cron,秒必須為 0", + "receivers": "通知接收者", + "template": "通知範本", + "templateHint": "自訂範本要求所有接收者類型一致", + "defaultTemplate": "使用各接收者類型的預設範本", + "enabled": "儲存後啟用", + "save": "儲存定時調查" + }, + "transcript": { + "title": "執行記錄 · {{name}}", + "loading": "正在載入執行記錄…", + "empty": "這個定時調查尚無執行記錄。", + "unavailable": "執行記錄暫時無法使用。", + "loadEarlier": "載入更早記錄", + "noText": "沒有文字內容" + } } } diff --git a/web-app/src/core/http/api-message.ts b/web-app/src/core/http/api-message.ts index ceecdef809..5f2edb3490 100644 --- a/web-app/src/core/http/api-message.ts +++ b/web-app/src/core/http/api-message.ts @@ -88,6 +88,14 @@ export function apiMessagePut( return apiMessageRequest(path, jsonRequest('PUT', data, options)); } +export function apiMessagePatch( + path: string, + data: unknown, + options?: Pick<RequestInit, 'signal' | 'headers'> +): Promise<unknown> { + return apiMessageRequest(path, jsonRequest('PATCH', data, options)); +} + export function apiMessageDelete(path: string, options?: Pick<RequestInit, 'signal' | 'headers'>): Promise<unknown> { return apiMessageRequest(path, { ...options, method: 'DELETE' }); } @@ -128,7 +136,7 @@ async function parseApiEnvelope(response: Response) { } function jsonRequest( - method: 'POST' | 'PUT', + method: 'POST' | 'PUT' | 'PATCH', data: unknown, options?: Pick<RequestInit, 'signal' | 'headers'> ): RequestInit { diff --git a/web-app/src/features/ai-workspace/api/agent-schedule-api.test.ts b/web-app/src/features/ai-workspace/api/agent-schedule-api.test.ts new file mode 100644 index 0000000000..8e05830935 --- /dev/null +++ b/web-app/src/features/ai-workspace/api/agent-schedule-api.test.ts @@ -0,0 +1,135 @@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createAgentSchedule, + deleteAgentSchedule, + listAgentScheduleOptions, + listAgentScheduleTranscript, + listAgentSchedules, + runAgentSchedule, + toggleAgentSchedule, + updateAgentSchedule +} from './agent-schedule-api'; + +const schedule = { + id: 7, + name: 'Daily inspection', + instruction: 'Inspect critical services', + cronExpression: '0 0 9 * * *', + enabled: true, + sessionId: 11, + receiverIds: [3], + templateId: null, + createdFromSessionUid: null, + lastTriggerAt: null, + nextTriggerAt: 1_800_000_000_000, + creator: 'admin', + modifier: 'admin', + gmtCreate: '2026-08-14T08:00:00', + gmtUpdate: '2026-08-14T08:00:00' +}; + +describe('Agent schedule browser API', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('uses exact schedule CRUD, toggle, and run endpoints', async () => { + const fetchMock = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(message(page([schedule]))) + .mockResolvedValueOnce(message(schedule)) + .mockResolvedValueOnce(message({ ...schedule, name: 'Updated' })) + .mockResolvedValueOnce(message({ ...schedule, enabled: false })) + .mockResolvedValueOnce(message({ runUid: 'run-1', status: 'CREATED' })) + .mockResolvedValueOnce(message(null)); + vi.stubGlobal('fetch', fetchMock); + + await listAgentSchedules(0, 20); + await createAgentSchedule(schedule); + await updateAgentSchedule(7, { ...schedule, name: 'Updated' }); + await toggleAgentSchedule(7, false); + await runAgentSchedule(7); + await deleteAgentSchedule(7); + + expect(fetchMock.mock.calls.map(call => [call[0], call[1]?.method ?? 'GET'])).toEqual([ + ['/api/agent/schedules?pageIndex=0&pageSize=20', 'GET'], + ['/api/agent/schedules', 'POST'], + ['/api/agent/schedules/7', 'PUT'], + ['/api/agent/schedules/7/enabled?enabled=false', 'PATCH'], + ['/api/agent/schedules/7/run', 'POST'], + ['/api/agent/schedules/7', 'DELETE'] + ]); + }); + + it('loads receiver and template choices without exposing their secret fields', async () => { + const fetchMock = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(message([{ id: 3, name: 'Operations', type: 1 }])) + .mockResolvedValueOnce(message([{ id: 5, name: 'AI report', type: 1, preset: false, content: 'private' }])); + vi.stubGlobal('fetch', fetchMock); + + const options = await listAgentScheduleOptions(); + + expect(options).toEqual({ + receivers: [{ id: 3, name: 'Operations', type: 1 }], + templates: [{ id: 5, name: 'AI report', type: 1 }] + }); + expect(fetchMock.mock.calls.map(call => call[0])).toEqual([ + '/api/notice/receivers/all', + '/api/notice/templates/all' + ]); + }); + + it('loads the fixed schedule transcript and exposes text blocks only', async () => { + const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( + message( + page([ + { + id: 19, + sessionSequence: 2, + messageRole: 'ASSISTANT', + payloadJson: JSON.stringify({ + role: 'assistant', + content: [ + { type: 'text', text: 'Database saturation is stable.' }, + { type: 'tool_result', text: 'private tool payload' } + ] + }), + gmtCreate: '2026-08-14T09:00:00' + } + ]) + ) + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(listAgentScheduleTranscript(7)).resolves.toEqual({ + entries: [ + { + id: 19, + sequence: 2, + role: 'assistant', + text: 'Database saturation is stable.', + createdAt: '2026-08-14T09:00:00' + } + ], + pageIndex: 0, + hasEarlier: false + }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/agent/schedules/7/transcript?pageIndex=0&pageSize=20', + expect.objectContaining({ credentials: 'same-origin' }) + ); + }); +}); + +function page(content: unknown[]) { + return { content, totalElements: content.length, totalPages: 1, number: 0, size: 20 }; +} + +function message(data: unknown) { + return new Response(JSON.stringify({ code: 0, msg: null, data }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); +} diff --git a/web-app/src/features/ai-workspace/api/agent-schedule-api.ts b/web-app/src/features/ai-workspace/api/agent-schedule-api.ts new file mode 100644 index 0000000000..641ac9c260 --- /dev/null +++ b/web-app/src/features/ai-workspace/api/agent-schedule-api.ts @@ -0,0 +1,122 @@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { + apiMessageDelete, + apiMessageGet, + apiMessagePatch, + apiMessagePost, + apiMessagePut +} from '@/core/http/api-message'; + +import type { + AgentScheduleDraft, + AgentScheduleOptions, + AgentScheduleTranscriptEntry, + AgentScheduleTranscriptPage +} from '../model/agent-schedule-contract'; +import { + agentSchedulePageSchema, + agentScheduleRunSchema, + agentScheduleSchema, + agentScheduleTemplateSchema, + agentScheduleTranscriptSchema, + agentScheduleOptionSchema +} from './agent-schedule-schema'; + +const schedulesPath = '/api/agent/schedules'; + +export function listAgentSchedules(pageIndex: number, pageSize: number, signal?: AbortSignal) { + return parse( + agentSchedulePageSchema, + apiMessageGet(`${schedulesPath}?pageIndex=${pageIndex}&pageSize=${pageSize}`, signal ? { signal } : {}) + ); +} + +export function createAgentSchedule(draft: AgentScheduleDraft) { + return parse(agentScheduleSchema, apiMessagePost(schedulesPath, payload(draft))); +} + +export function updateAgentSchedule(scheduleId: number, draft: AgentScheduleDraft) { + return parse(agentScheduleSchema, apiMessagePut(`${schedulesPath}/${scheduleId}`, payload(draft))); +} + +export function toggleAgentSchedule(scheduleId: number, enabled: boolean) { + return parse(agentScheduleSchema, apiMessagePatch(`${schedulesPath}/${scheduleId}/enabled?enabled=${enabled}`, {})); +} + +export function runAgentSchedule(scheduleId: number) { + return parse(agentScheduleRunSchema, apiMessagePost(`${schedulesPath}/${scheduleId}/run`, {})); +} + +export async function deleteAgentSchedule(scheduleId: number) { + await apiMessageDelete(`${schedulesPath}/${scheduleId}`); +} + +export async function listAgentScheduleOptions(signal?: AbortSignal): Promise<AgentScheduleOptions> { + const request = signal ? { signal } : {}; + const [receiverValue, templateValue] = await Promise.all([ + apiMessageGet('/api/notice/receivers/all', request), + apiMessageGet('/api/notice/templates/all', request) + ]); + const receivers = agentScheduleOptionSchema.array().parse(receiverValue); + const templates = agentScheduleTemplateSchema + .array() + .parse(templateValue) + .flatMap(option => (option.id == null ? [] : [{ id: option.id, name: option.name, type: option.type }])); + return { receivers, templates }; +} + +export async function listAgentScheduleTranscript( + scheduleId: number, + pageIndex = 0, + pageSize = 20, + signal?: AbortSignal +): Promise<AgentScheduleTranscriptPage> { + const value = await apiMessageGet( + `${schedulesPath}/${scheduleId}/transcript?pageIndex=${pageIndex}&pageSize=${pageSize}`, + signal ? { signal } : {} + ); + const page = agentScheduleTranscriptSchema.parse(value); + return { + entries: page.content.flatMap(entry => transcriptEntry(entry)), + pageIndex: page.number, + hasEarlier: page.number + 1 < page.totalPages + }; +} + +function payload(draft: AgentScheduleDraft): AgentScheduleDraft { + return { + name: draft.name.trim(), + instruction: draft.instruction.trim(), + cronExpression: draft.cronExpression.trim(), + enabled: draft.enabled, + receiverIds: [...draft.receiverIds], + templateId: draft.templateId + }; +} + +async function parse<T>(schema: { parse: (value: unknown) => T }, value: Promise<unknown>): Promise<T> { + return schema.parse(await value); +} + +function transcriptEntry(entry: { + id: number; + sessionSequence: number; + payloadJson: string; + gmtCreate: string | null; +}): AgentScheduleTranscriptEntry[] { + try { + const payload = JSON.parse(entry.payloadJson) as { + role?: unknown; + content?: Array<{ type?: string; text?: unknown }>; + }; + if (payload.role !== 'user' && payload.role !== 'assistant') return []; + const role = payload.role; + const text = (payload.content ?? []) + .flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])) + .join('\n'); + return text ? [{ id: entry.id, sequence: entry.sessionSequence, role, text, createdAt: entry.gmtCreate }] : []; + } catch { + return []; + } +} diff --git a/web-app/src/features/ai-workspace/api/agent-schedule-schema.ts b/web-app/src/features/ai-workspace/api/agent-schedule-schema.ts new file mode 100644 index 0000000000..ad0dde7ad5 --- /dev/null +++ b/web-app/src/features/ai-workspace/api/agent-schedule-schema.ts @@ -0,0 +1,49 @@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +import { z } from 'zod'; + +import { springPageSchema } from './agent-gateway-schema'; + +export const agentScheduleSchema = z + .object({ + id: z.number().int().positive(), + name: z.string().min(1), + instruction: z.string().min(1), + cronExpression: z.string().min(1), + enabled: z.boolean(), + sessionId: z.number().int().positive().nullable(), + receiverIds: z.array(z.number().int().positive()), + templateId: z.number().int().positive().nullable(), + createdFromSessionUid: z.string().nullable(), + lastTriggerAt: z.number().int().nonnegative().nullable(), + nextTriggerAt: z.number().int().nonnegative().nullable(), + creator: z.string().nullable(), + modifier: z.string().nullable(), + gmtCreate: z.string().nullable(), + gmtUpdate: z.string().nullable() + }) + .passthrough(); + +export const agentSchedulePageSchema = springPageSchema(agentScheduleSchema); + +export const agentScheduleOptionSchema = z + .object({ id: z.number().int().positive(), name: z.string().min(1), type: z.number().int().nonnegative() }) + .passthrough(); + +export const agentScheduleTemplateSchema = z + .object({ id: z.number().int().positive().nullable(), name: z.string().min(1), type: z.number().int().nonnegative() }) + .passthrough(); + +export const agentScheduleRunSchema = z.object({ runUid: z.string().min(1), status: z.string().min(1) }).passthrough(); + +export const agentScheduleTranscriptSchema = springPageSchema( + z + .object({ + id: z.number().int().positive(), + sessionSequence: z.number().int().positive(), + payloadJson: z.string(), + messageRole: z.string().min(1), + gmtCreate: z.string().nullable() + }) + .passthrough() +); diff --git a/web-app/src/features/ai-workspace/components/agent-schedule-editor.tsx b/web-app/src/features/ai-workspace/components/agent-schedule-editor.tsx new file mode 100644 index 0000000000..a16946cbad --- /dev/null +++ b/web-app/src/features/ai-workspace/components/agent-schedule-editor.tsx @@ -0,0 +1,137 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Input, Modal, Select, Switch, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { AgentScheduleViewModel } from '../model/agent-schedule-view-model'; +import styles from './agent-schedule-view.module.css'; + +export function AgentScheduleEditor({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + const editor = controller.editor; + if (!editor) return null; + const draft = editor.draft; + const receiverTypes = selectedReceiverTypes(controller); + const templates = + receiverTypes.size === 1 ? controller.options.templates.filter(option => receiverTypes.has(option.type)) : []; + const valid = + draft.name.trim() && draft.instruction.trim() && draft.cronExpression.trim() && draft.receiverIds.length; + return ( + <Modal + open + destroyOnHidden + title={t(editor.mode === 'create' ? 'aiSchedules.editor.createTitle' : 'aiSchedules.editor.editTitle')} + okText={t('aiSchedules.editor.save')} + cancelText={t('common.cancel')} + okButtonProps={{ disabled: !valid, loading: controller.busy === 'save' }} + onOk={() => void controller.actions.save()} + onCancel={controller.actions.closeEditor} + > + <div className={styles.editor}> + <EditorTextFields controller={controller} /> + <ScheduleField label={t('aiSchedules.editor.receivers')}> + <Select + mode="multiple" + value={draft.receiverIds} + options={controller.options.receivers.map(option => ({ value: option.id, label: option.name }))} + onChange={receiverIds => updateReceivers(controller, receiverIds)} + /> + </ScheduleField> + <ScheduleField label={t('aiSchedules.editor.template')} hint={t('aiSchedules.editor.templateHint')}> + <Select + allowClear + value={draft.templateId ?? undefined} + disabled={receiverTypes.size !== 1} + placeholder={t('aiSchedules.editor.defaultTemplate')} + options={templates.map(option => ({ value: option.id, label: option.name }))} + onChange={templateId => controller.actions.updateDraft({ templateId: templateId ?? null })} + /> + </ScheduleField> + {editor.mode === 'create' ? <EnabledField controller={controller} /> : null} + </div> + </Modal> + ); +} + +function EditorTextFields({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + const draft = controller.editor!.draft; + return ( + <> + <ScheduleField label={t('aiSchedules.editor.name')}> + <Input + value={draft.name} + maxLength={128} + onChange={event => controller.actions.updateDraft({ name: event.target.value })} + /> + </ScheduleField> + <ScheduleField label={t('aiSchedules.editor.instruction')}> + <Input.TextArea + value={draft.instruction} + rows={5} + maxLength={4096} + onChange={event => controller.actions.updateDraft({ instruction: event.target.value })} + /> + </ScheduleField> + <ScheduleField label={t('aiSchedules.editor.cron')} hint={t('aiSchedules.editor.cronHint')}> + <Input + value={draft.cronExpression} + maxLength={64} + onChange={event => controller.actions.updateDraft({ cronExpression: event.target.value })} + /> + </ScheduleField> + </> + ); +} + +function EnabledField({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + return ( + <div className={styles.fieldHeader}> + <Typography.Text>{t('aiSchedules.editor.enabled')}</Typography.Text> + <Switch + checked={controller.editor!.draft.enabled} + onChange={enabled => controller.actions.updateDraft({ enabled })} + /> + </div> + ); +} + +function ScheduleField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( + <label className={styles.field}> + <span className={styles.fieldHeader}> + <Typography.Text>{label}</Typography.Text> + {hint ? <span className={styles.fieldHint}>{hint}</span> : null} + </span> + {children} + </label> + ); +} + +function selectedReceiverTypes(controller: AgentScheduleViewModel) { + const receiverIds = controller.editor!.draft.receiverIds; + return new Set( + controller.options.receivers.filter(option => receiverIds.includes(option.id)).map(option => option.type) + ); +} + +function updateReceivers(controller: AgentScheduleViewModel, receiverIds: number[]) { + const selectedTypes = new Set( + controller.options.receivers.filter(receiver => receiverIds.includes(receiver.id)).map(receiver => receiver.type) + ); + const selectedTemplate = controller.options.templates.find( + template => template.id === controller.editor?.draft.templateId + ); + controller.actions.updateDraft({ + receiverIds, + templateId: + selectedTypes.size === 1 && selectedTemplate && selectedTypes.has(selectedTemplate.type) + ? selectedTemplate.id + : null + }); +} diff --git a/web-app/src/features/ai-workspace/components/agent-schedule-table.tsx b/web-app/src/features/ai-workspace/components/agent-schedule-table.tsx new file mode 100644 index 0000000000..dbe729a1c3 --- /dev/null +++ b/web-app/src/features/ai-workspace/components/agent-schedule-table.tsx @@ -0,0 +1,138 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Button, Popconfirm, Space, Switch, Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslation } from 'react-i18next'; + +import type { AgentSchedule } from '../model/agent-schedule-contract'; +import type { AgentScheduleViewModel } from '../model/agent-schedule-view-model'; +import styles from './agent-schedule-view.module.css'; + +type Translate = (key: string) => string; + +export function AgentScheduleTable({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + return ( + <Table + rowKey="id" + size="small" + columns={scheduleColumns(controller, t)} + dataSource={controller.list.items} + pagination={{ + current: controller.list.pageIndex + 1, + pageSize: controller.list.pageSize, + total: controller.list.total, + showSizeChanger: true, + onChange: (page, size) => void controller.actions.setPage(page - 1, size) + }} + /> + ); +} + +function scheduleColumns(controller: AgentScheduleViewModel, t: Translate): ColumnsType<AgentSchedule> { + const busy = controller.busy !== null; + return [ + { + title: t('aiSchedules.columns.schedule'), + render: (_, schedule) => ( + <span className={styles.scheduleName}> + <Typography.Text strong>{schedule.name}</Typography.Text> + <small>{schedule.cronExpression}</small> + </span> + ) + }, + { + title: t('aiSchedules.columns.instruction'), + dataIndex: 'instruction', + render: value => <span className={styles.scheduleInstruction}>{value}</span> + }, + deliveryColumn(controller, t), + { + title: t('aiSchedules.columns.nextRun'), + dataIndex: 'nextTriggerAt', + width: 170, + render: (value: number | null) => ( + <span className={styles.scheduleTime}>{formatTimestamp(value, t('aiSchedules.never'))}</span> + ) + }, + { + title: t('aiSchedules.columns.enabled'), + width: 90, + render: (_, schedule) => ( + <Switch + checked={schedule.enabled} + disabled={busy} + aria-label={t('aiSchedules.columns.enabled')} + onChange={enabled => void controller.actions.toggle(schedule.id, enabled)} + /> + ) + }, + actionsColumn(controller, t) + ]; +} + +function deliveryColumn(controller: AgentScheduleViewModel, t: Translate): ColumnsType<AgentSchedule>[number] { + return { + title: t('aiSchedules.columns.delivery'), + width: 210, + render: (_, schedule) => ( + <span className={styles.scheduleName}> + <span>{receiverNames(controller, schedule)}</span> + <small>{templateName(controller, schedule, t('aiSchedules.editor.defaultTemplate'))}</small> + </span> + ) + }; +} + +function actionsColumn(controller: AgentScheduleViewModel, t: Translate): ColumnsType<AgentSchedule>[number] { + const busy = controller.busy !== null; + return { + title: t('aiSchedules.columns.actions'), + width: 330, + render: (_, schedule) => ( + <Space size="small" wrap> + <Button size="small" disabled={busy} onClick={() => void controller.actions.run(schedule.id)}> + {t('aiSchedules.actions.runNow')} + </Button> + <Button size="small" disabled={busy} onClick={() => void controller.actions.openTranscript(schedule)}> + {t('aiSchedules.actions.transcript')} + </Button> + <Button size="small" disabled={busy} onClick={() => controller.actions.openEdit(schedule)}> + {t('aiSchedules.actions.edit')} + </Button> + <Popconfirm + title={t('aiSchedules.actions.deleteConfirm')} + onConfirm={() => void controller.actions.delete(schedule.id)} + > + <Button size="small" danger disabled={busy}> + {t('aiSchedules.actions.delete')} + </Button> + </Popconfirm> + </Space> + ) + }; +} + +function receiverNames(controller: AgentScheduleViewModel, schedule: AgentSchedule) { + return schedule.receiverIds + .map(id => controller.options.receivers.find(receiver => receiver.id === id)?.name ?? String(id)) + .join(', '); +} + +function templateName(controller: AgentScheduleViewModel, schedule: AgentSchedule, fallback: string) { + if (schedule.templateId === null) return fallback; + return ( + controller.options.templates.find(template => template.id === schedule.templateId)?.name ?? + String(schedule.templateId) + ); +} + +function formatTimestamp(value: number | null, fallback: string) { + return value === null + ? fallback + : new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(value); +} diff --git a/web-app/src/features/ai-workspace/components/agent-schedule-transcript.tsx b/web-app/src/features/ai-workspace/components/agent-schedule-transcript.tsx new file mode 100644 index 0000000000..57a1f135d7 --- /dev/null +++ b/web-app/src/features/ai-workspace/components/agent-schedule-transcript.tsx @@ -0,0 +1,55 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Alert, Button, Modal, Space, Tag, Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { AgentScheduleViewModel } from '../model/agent-schedule-view-model'; +import styles from './agent-schedule-view.module.css'; + +export function AgentScheduleTranscript({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + const transcript = controller.transcript; + return ( + <Modal + open={transcript.open} + footer={null} + width={760} + title={t('aiSchedules.transcript.title', { name: transcript.schedule?.name ?? '' })} + onCancel={controller.actions.closeTranscript} + > + {transcript.kind === 'loading' ? <Typography.Text>{t('aiSchedules.transcript.loading')}</Typography.Text> : null} + {transcript.kind === 'empty' ? <Typography.Text>{t('aiSchedules.transcript.empty')}</Typography.Text> : null} + {transcript.kind === 'error' ? ( + <Alert type="error" showIcon message={t('aiSchedules.transcript.unavailable')} /> + ) : null} + {transcript.kind === 'ready' ? <TranscriptEntries controller={controller} /> : null} + </Modal> + ); +} + +function TranscriptEntries({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + const transcript = controller.transcript; + return ( + <div className={styles.transcript}> + {transcript.hasEarlier ? ( + <Button loading={transcript.loadingEarlier} onClick={() => void controller.actions.loadEarlierTranscript()}> + {t('aiSchedules.transcript.loadEarlier')} + </Button> + ) : null} + {transcript.entries.map(entry => ( + <article className={styles.transcriptEntry} key={entry.id}> + <Space> + <Tag>{entry.role}</Tag> + <Typography.Text type="secondary">{entry.createdAt ?? ''}</Typography.Text> + </Space> + <p>{entry.text || t('aiSchedules.transcript.noText')}</p> + </article> + ))} + </div> + ); +} diff --git a/web-app/src/features/ai-workspace/components/agent-schedule-view.module.css b/web-app/src/features/ai-workspace/components/agent-schedule-view.module.css new file mode 100644 index 0000000000..0c93de5d9e --- /dev/null +++ b/web-app/src/features/ai-workspace/components/agent-schedule-view.module.css @@ -0,0 +1,76 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: var(--hb-space-3); +} + +.scheduleName { + display: grid; + gap: 2px; + min-width: 180px; +} + +.scheduleName small, +.scheduleTime { + color: var(--hb-text-secondary); +} + +.scheduleInstruction { + display: -webkit-box; + max-width: 420px; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-height: 1.45; +} + +.failure { + margin-bottom: var(--hb-space-3); +} + +.editor { + display: grid; + gap: var(--hb-space-4); +} + +.field { + display: grid; + gap: var(--hb-space-2); +} + +.fieldHeader { + display: flex; + justify-content: space-between; + gap: var(--hb-space-3); +} + +.fieldHint { + color: var(--hb-text-secondary); + font-size: 12px; +} + +.transcript { + display: grid; + gap: var(--hb-space-4); + max-height: 60vh; + overflow: auto; +} + +.transcriptEntry { + display: grid; + gap: var(--hb-space-1); + padding-left: var(--hb-space-3); + border-left: 2px solid var(--hb-border-subtle); +} + +.transcriptEntry p { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/web-app/src/features/ai-workspace/components/agent-schedule-view.tsx b/web-app/src/features/ai-workspace/components/agent-schedule-view.tsx new file mode 100644 index 0000000000..9b7635b434 --- /dev/null +++ b/web-app/src/features/ai-workspace/components/agent-schedule-view.tsx @@ -0,0 +1,65 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Alert, Button } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { OperationalStatePanel } from '@/shared/operational-page'; + +import type { AgentScheduleViewModel } from '../model/agent-schedule-view-model'; +import { AgentScheduleEditor } from './agent-schedule-editor'; +import { AgentScheduleTable } from './agent-schedule-table'; +import { AgentScheduleTranscript } from './agent-schedule-transcript'; +import styles from './agent-schedule-view.module.css'; + +export function AgentScheduleView({ controller }: { controller: AgentScheduleViewModel }) { + if (controller.list.kind !== 'ready') { + return <ScheduleState controller={controller} />; + } + return ( + <> + <MutationFailure controller={controller} /> + <AgentScheduleTable controller={controller} /> + <AgentScheduleEditor controller={controller} /> + <AgentScheduleTranscript controller={controller} /> + </> + ); +} + +function ScheduleState({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + if (controller.list.kind === 'loading') { + return <OperationalStatePanel kind="loading" title={t('aiSchedules.states.loading')} />; + } + if (controller.list.kind === 'empty') { + return ( + <> + <MutationFailure controller={controller} /> + <OperationalStatePanel + kind="empty" + title={t('aiSchedules.states.empty')} + description={t('aiSchedules.states.emptyDescription')} + action={<Button onClick={controller.actions.openCreate}>{t('aiSchedules.create')}</Button>} + /> + <AgentScheduleEditor controller={controller} /> + </> + ); + } + return ( + <OperationalStatePanel + kind="unavailable" + title={t('aiSchedules.states.unavailable')} + action={<Button onClick={() => void controller.actions.reload()}>{t('common.retry')}</Button>} + /> + ); +} + +function MutationFailure({ controller }: { controller: AgentScheduleViewModel }) { + const { t } = useTranslation(); + return controller.mutationFailed ? ( + <Alert className={styles.failure ?? ''} type="error" showIcon message={t('aiSchedules.states.mutationFailed')} /> + ) : null; +} diff --git a/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx b/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx index 331095c830..79fe936ca6 100644 --- a/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx +++ b/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx @@ -1,6 +1,6 @@ /* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ -import { SettingOutlined } from '@ant-design/icons'; +import { ScheduleOutlined, SettingOutlined } from '@ant-design/icons'; import { Button, Typography } from 'antd'; import { useTranslation } from 'react-i18next'; @@ -10,11 +10,13 @@ import styles from './agent-workspace-view.module.css'; export function AgentWorkspaceSessionPane({ controller, isAdmin, - onOpenProviders + onOpenProviders, + onOpenSchedules }: { controller: AgentWorkspaceViewModel; isAdmin: boolean; onOpenProviders: () => void; + onOpenSchedules: () => void; }) { const { t } = useTranslation(); return ( @@ -41,9 +43,14 @@ export function AgentWorkspaceSessionPane({ ))} </div> {isAdmin ? ( - <Button className={styles.providerButton ?? ''} icon={<SettingOutlined />} onClick={onOpenProviders}> - {t('aiWorkspace.providers.action')} - </Button> + <div className={styles.adminActions}> + <Button aria-label={t('aiSchedules.workspaceAction')} icon={<ScheduleOutlined />} onClick={onOpenSchedules}> + {t('aiSchedules.workspaceAction')} + </Button> + <Button icon={<SettingOutlined />} onClick={onOpenProviders}> + {t('aiWorkspace.providers.action')} + </Button> + </div> ) : null} </nav> ); diff --git a/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css b/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css index 7a4ae1ce24..122f294631 100644 --- a/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css +++ b/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css @@ -75,7 +75,9 @@ .sessionButton small { color: var(--hb-text-secondary); } -.providerButton { +.adminActions { + display: grid; + gap: var(--hb-space-2); margin: auto var(--hb-space-3) var(--hb-space-3); } diff --git a/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx b/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx index 3a697396cc..00d0a817bc 100644 --- a/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx +++ b/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx @@ -17,9 +17,10 @@ describe('AgentWorkspaceView', () => { it('keeps investigation history, the streamed answer, and exact action review in separate regions', () => { const controller = fixture(); + const openSchedules = vi.fn(); const view = render( <I18nextProvider i18n={i18n}> - <AgentWorkspaceView controller={controller} isAdmin onOpenProviders={vi.fn()} /> + <AgentWorkspaceView controller={controller} isAdmin onOpenProviders={vi.fn()} onOpenSchedules={openSchedules} /> </I18nextProvider> ); @@ -32,6 +33,8 @@ describe('AgentWorkspaceView', () => { expect(context).toHaveTextContent('monitor.disable'); expect(within(context).getByRole('button', { name: 'Approve' })).toBeInTheDocument(); expect(within(context).getByRole('button', { name: 'Reject' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Scheduled investigations' })); + expect(openSchedules).toHaveBeenCalledOnce(); fireEvent.change(screen.getByRole('textbox', { name: 'Investigation prompt' }), { target: { value: 'Check database waits' } @@ -42,6 +45,7 @@ describe('AgentWorkspaceView', () => { controller={{ ...controller, composer: 'Check database waits' }} isAdmin onOpenProviders={vi.fn()} + onOpenSchedules={openSchedules} /> </I18nextProvider> ); diff --git a/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx b/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx index acb2d0ec93..5038d2cf94 100644 --- a/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx +++ b/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx @@ -9,15 +9,22 @@ import styles from './agent-workspace-view.module.css'; export function AgentWorkspaceView({ controller, isAdmin, - onOpenProviders + onOpenProviders, + onOpenSchedules }: { controller: AgentWorkspaceViewModel; isAdmin: boolean; onOpenProviders: () => void; + onOpenSchedules: () => void; }) { return ( <div className={styles.workspace}> - <AgentWorkspaceSessionPane controller={controller} isAdmin={isAdmin} onOpenProviders={onOpenProviders} /> + <AgentWorkspaceSessionPane + controller={controller} + isAdmin={isAdmin} + onOpenProviders={onOpenProviders} + onOpenSchedules={onOpenSchedules} + /> <AgentWorkspaceConversation controller={controller} /> <AgentWorkspaceContextPane controller={controller} isAdmin={isAdmin} /> </div> diff --git a/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.test.tsx b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.test.tsx new file mode 100644 index 0000000000..ca05d6e7cb --- /dev/null +++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.test.tsx @@ -0,0 +1,104 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const api = vi.hoisted(() => ({ + createAgentSchedule: vi.fn(), + deleteAgentSchedule: vi.fn(), + listAgentScheduleOptions: vi.fn(), + listAgentSchedules: vi.fn(), + listAgentScheduleTranscript: vi.fn(), + runAgentSchedule: vi.fn(), + toggleAgentSchedule: vi.fn(), + updateAgentSchedule: vi.fn() +})); +vi.mock('../api/agent-schedule-api', () => api); + +import { useAgentScheduleController } from './use-agent-schedule-controller'; + +describe('useAgentScheduleController', () => { + beforeEach(() => { + vi.clearAllMocks(); + api.listAgentSchedules.mockResolvedValue(page([schedule])); + api.listAgentScheduleOptions.mockResolvedValue(options); + api.createAgentSchedule.mockResolvedValue(schedule); + api.listAgentScheduleTranscript.mockResolvedValue({ entries: [transcript], pageIndex: 0, hasEarlier: false }); + }); + + it('loads options once with the page and creates a complete schedule draft', async () => { + const { result } = renderHook(() => useAgentScheduleController()); + await waitFor(() => expect(result.current.list.kind).toBe('ready')); + + act(() => result.current.actions.openCreate()); + act(() => + result.current.actions.updateDraft({ + name: 'Availability review', + instruction: 'Review availability.', + receiverIds: [11] + }) + ); + await act(() => result.current.actions.save()); + + expect(api.createAgentSchedule).toHaveBeenCalledWith({ + name: 'Availability review', + instruction: 'Review availability.', + cronExpression: '0 0 * * * *', + enabled: true, + receiverIds: [11], + templateId: null + }); + expect(api.listAgentSchedules).toHaveBeenCalledTimes(2); + expect(result.current.editor).toBeNull(); + }); + + it('loads the fixed transcript without rerunning the schedule', async () => { + const { result } = renderHook(() => useAgentScheduleController()); + await waitFor(() => expect(result.current.list.kind).toBe('ready')); + + await act(() => result.current.actions.openTranscript(schedule)); + + expect(api.listAgentScheduleTranscript).toHaveBeenCalledWith(7); + expect(api.runAgentSchedule).not.toHaveBeenCalled(); + expect(result.current.transcript).toMatchObject({ kind: 'ready', entries: [transcript] }); + }); +}); + +const schedule = { + id: 7, + name: 'Daily database review', + instruction: 'Review database saturation.', + cronExpression: '0 0 9 * * *', + enabled: true, + sessionId: 9, + receiverIds: [11], + templateId: 21, + createdFromSessionUid: null, + lastTriggerAt: null, + nextTriggerAt: 1_786_678_800_000, + creator: 'admin', + modifier: 'admin', + gmtCreate: null, + gmtUpdate: null +}; + +const options = { + receivers: [{ id: 11, name: 'On-call email', type: 1 }], + templates: [{ id: 21, name: 'AI review', type: 1 }] +}; + +const transcript = { + id: 19, + sequence: 2, + role: 'assistant' as const, + text: 'Database saturation is stable.', + createdAt: '2026-08-14T09:00:00' +}; + +function page(content: (typeof schedule)[]) { + return { content, totalElements: content.length, totalPages: 1, number: 0, size: 20 }; +} diff --git a/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.ts b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.ts new file mode 100644 index 0000000000..c9ee5bd58a --- /dev/null +++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.ts @@ -0,0 +1,36 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { useMemo } from 'react'; + +import type { AgentScheduleViewModel } from '../model/agent-schedule-view-model'; +import { useAgentScheduleEditor } from './use-agent-schedule-editor'; +import { useAgentScheduleList } from './use-agent-schedule-list'; +import { useAgentScheduleTranscript } from './use-agent-schedule-transcript'; + +export function useAgentScheduleController(): AgentScheduleViewModel { + const scheduleList = useAgentScheduleList(); + const scheduleEditor = useAgentScheduleEditor(scheduleList.list, scheduleList.load); + const scheduleTranscript = useAgentScheduleTranscript(scheduleEditor.reportFailure); + const actions = useMemo<AgentScheduleViewModel['actions']>( + () => ({ + reload: scheduleList.reload, + setPage: scheduleList.load, + ...scheduleEditor.actions, + ...scheduleTranscript.actions + }), + [scheduleEditor.actions, scheduleList.load, scheduleList.reload, scheduleTranscript.actions] + ); + return { + list: scheduleList.list, + options: scheduleList.options, + editor: scheduleEditor.editor, + transcript: scheduleTranscript.transcript, + busy: scheduleEditor.busy, + mutationFailed: scheduleEditor.mutationFailed, + actions + }; +} diff --git a/web-app/src/features/ai-workspace/controller/use-agent-schedule-editor.ts b/web-app/src/features/ai-workspace/controller/use-agent-schedule-editor.ts new file mode 100644 index 0000000000..624c9d91c1 --- /dev/null +++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-editor.ts @@ -0,0 +1,107 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { useCallback, useMemo, useState } from 'react'; + +import { + createAgentSchedule, + deleteAgentSchedule, + runAgentSchedule, + toggleAgentSchedule, + updateAgentSchedule +} from '../api/agent-schedule-api'; +import type { AgentSchedule, AgentScheduleDraft } from '../model/agent-schedule-contract'; +import type { AgentScheduleEditorState, AgentScheduleListState } from '../model/agent-schedule-view-model'; + +type LoadSchedules = (pageIndex: number, pageSize: number, signal?: AbortSignal) => Promise<void>; + +export function useAgentScheduleEditor(list: AgentScheduleListState, load: LoadSchedules) { + const [editor, setEditor] = useState<AgentScheduleEditorState | null>(null); + const [busy, setBusy] = useState<string | null>(null); + const [mutationFailed, setMutationFailed] = useState(false); + const mutate = useScheduleMutation(list, load, setBusy, setMutationFailed); + const actions = useMemo( + () => ({ + openCreate: () => setEditor({ mode: 'create' as const, scheduleId: null, draft: newDraft() }), + openEdit: (schedule: AgentSchedule) => setEditor(editorFor(schedule)), + closeEditor: () => setEditor(null), + updateDraft: (patch: Partial<AgentScheduleDraft>) => + setEditor(current => (current ? { ...current, draft: { ...current.draft, ...patch } } : null)), + save: async () => { + if (!editor) return; + const saved = await mutate('save', () => + editor.scheduleId === null + ? createAgentSchedule(editor.draft) + : updateAgentSchedule(editor.scheduleId, editor.draft) + ); + if (saved) setEditor(null); + }, + toggle: async (scheduleId: number, enabled: boolean) => { + await mutate(`toggle:${scheduleId}`, () => toggleAgentSchedule(scheduleId, enabled)); + }, + run: async (scheduleId: number) => { + await mutate(`run:${scheduleId}`, () => runAgentSchedule(scheduleId)); + }, + delete: async (scheduleId: number) => { + await mutate(`delete:${scheduleId}`, () => deleteAgentSchedule(scheduleId)); + } + }), + [editor, mutate] + ); + const reportFailure = useCallback(() => setMutationFailed(true), []); + return { editor, busy, mutationFailed, reportFailure, actions }; +} + +function useScheduleMutation( + list: AgentScheduleListState, + load: LoadSchedules, + setBusy: (value: string | null) => void, + setMutationFailed: (value: boolean) => void +) { + return useCallback( + async (key: string, action: () => Promise<unknown>) => { + setBusy(key); + setMutationFailed(false); + try { + await action(); + await load(list.pageIndex, list.pageSize); + return true; + } catch { + setMutationFailed(true); + return false; + } finally { + setBusy(null); + } + }, + [list.pageIndex, list.pageSize, load, setBusy, setMutationFailed] + ); +} + +function newDraft(): AgentScheduleDraft { + return { + name: '', + instruction: '', + cronExpression: '0 0 * * * *', + enabled: true, + receiverIds: [], + templateId: null + }; +} + +function editorFor(schedule: AgentSchedule): AgentScheduleEditorState { + return { + mode: 'edit', + scheduleId: schedule.id, + draft: { + name: schedule.name, + instruction: schedule.instruction, + cronExpression: schedule.cronExpression, + enabled: schedule.enabled, + receiverIds: [...schedule.receiverIds], + templateId: schedule.templateId + } + }; +} diff --git a/web-app/src/features/ai-workspace/controller/use-agent-schedule-list.ts b/web-app/src/features/ai-workspace/controller/use-agent-schedule-list.ts new file mode 100644 index 0000000000..cda9f32342 --- /dev/null +++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-list.ts @@ -0,0 +1,58 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { listAgentScheduleOptions, listAgentSchedules } from '../api/agent-schedule-api'; +import type { AgentScheduleOptions } from '../model/agent-schedule-contract'; +import type { AgentScheduleListState } from '../model/agent-schedule-view-model'; + +const pageSize = 20; +const initialList: AgentScheduleListState = { + kind: 'loading', + items: [], + total: 0, + pageIndex: 0, + pageSize +}; + +export function useAgentScheduleList() { + const [list, setList] = useState<AgentScheduleListState>(initialList); + const [options, setOptions] = useState<AgentScheduleOptions>({ receivers: [], templates: [] }); + const load = useCallback(async (nextPageIndex: number, nextPageSize: number, signal?: AbortSignal) => { + setList(current => ({ ...current, kind: 'loading', pageIndex: nextPageIndex, pageSize: nextPageSize })); + try { + const [page, nextOptions] = await Promise.all([ + listAgentSchedules(nextPageIndex, nextPageSize, signal), + listAgentScheduleOptions(signal) + ]); + setOptions(nextOptions); + setList({ + kind: page.content.length === 0 ? 'empty' : 'ready', + items: page.content, + total: page.totalElements, + pageIndex: page.number, + pageSize: page.size + }); + } catch { + if (signal?.aborted) return; + setList({ kind: 'error', items: [], total: 0, pageIndex: nextPageIndex, pageSize: nextPageSize }); + } + }, []); + useInitialScheduleLoad(load); + const reload = useCallback(() => load(list.pageIndex, list.pageSize), [list.pageIndex, list.pageSize, load]); + return { list, options, load, reload }; +} + +function useInitialScheduleLoad(load: (pageIndex: number, pageSize: number, signal?: AbortSignal) => Promise<void>) { + useEffect(() => { + const request = new AbortController(); + queueMicrotask(() => { + if (!request.signal.aborted) void load(0, pageSize, request.signal); + }); + return () => request.abort(); + }, [load]); +} diff --git a/web-app/src/features/ai-workspace/controller/use-agent-schedule-transcript.ts b/web-app/src/features/ai-workspace/controller/use-agent-schedule-transcript.ts new file mode 100644 index 0000000000..931c1099bf --- /dev/null +++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-transcript.ts @@ -0,0 +1,82 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { useCallback, useMemo, useState } from 'react'; + +import { listAgentScheduleTranscript } from '../api/agent-schedule-api'; +import type { AgentSchedule } from '../model/agent-schedule-contract'; +import type { AgentScheduleTranscriptState } from '../model/agent-schedule-view-model'; + +const initialTranscript: AgentScheduleTranscriptState = { + open: false, + schedule: null, + kind: 'idle', + entries: [], + pageIndex: 0, + hasEarlier: false, + loadingEarlier: false +}; + +export function useAgentScheduleTranscript(reportFailure: () => void) { + const [transcript, setTranscript] = useState<AgentScheduleTranscriptState>(initialTranscript); + const openTranscript = useCallback(async (schedule: AgentSchedule) => { + setTranscript({ ...initialTranscript, open: true, schedule, kind: 'loading' }); + try { + const page = await listAgentScheduleTranscript(schedule.id); + setTranscript({ + open: true, + schedule, + kind: page.entries.length === 0 ? 'empty' : 'ready', + entries: [...page.entries].reverse(), + pageIndex: page.pageIndex, + hasEarlier: page.hasEarlier, + loadingEarlier: false + }); + } catch { + setTranscript({ ...initialTranscript, open: true, schedule, kind: 'error' }); + } + }, []); + const loadEarlierTranscript = useLoadEarlierTranscript(transcript, setTranscript, reportFailure); + const actions = useMemo( + () => ({ + openTranscript, + loadEarlierTranscript, + closeTranscript: () => setTranscript(initialTranscript) + }), + [loadEarlierTranscript, openTranscript] + ); + return { transcript, actions }; +} + +function useLoadEarlierTranscript( + transcript: AgentScheduleTranscriptState, + setTranscript: React.Dispatch<React.SetStateAction<AgentScheduleTranscriptState>>, + reportFailure: () => void +) { + return useCallback(async () => { + if (!transcript.schedule || !transcript.hasEarlier || transcript.loadingEarlier) return; + const scheduleId = transcript.schedule.id; + setTranscript(value => ({ ...value, loadingEarlier: true })); + try { + const page = await listAgentScheduleTranscript(scheduleId, transcript.pageIndex + 1); + setTranscript(value => + value.schedule?.id === scheduleId + ? { + ...value, + kind: 'ready', + entries: [...page.entries].reverse().concat(value.entries), + pageIndex: page.pageIndex, + hasEarlier: page.hasEarlier, + loadingEarlier: false + } + : value + ); + } catch { + setTranscript(value => (value.schedule?.id === scheduleId ? { ...value, loadingEarlier: false } : value)); + reportFailure(); + } + }, [reportFailure, setTranscript, transcript]); +} diff --git a/web-app/src/features/ai-workspace/index.ts b/web-app/src/features/ai-workspace/index.ts index 8731cccfc5..4791d06a29 100644 --- a/web-app/src/features/ai-workspace/index.ts +++ b/web-app/src/features/ai-workspace/index.ts @@ -1,3 +1,4 @@ /* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ export { AgentWorkspacePage } from './pages/agent-workspace-page'; +export { AgentSchedulePage } from './pages/agent-schedule-page'; diff --git a/web-app/src/features/ai-workspace/model/agent-schedule-contract.ts b/web-app/src/features/ai-workspace/model/agent-schedule-contract.ts new file mode 100644 index 0000000000..67b69d96ba --- /dev/null +++ b/web-app/src/features/ai-workspace/model/agent-schedule-contract.ts @@ -0,0 +1,52 @@ +/* Licensed to the Apache Software Foundation (ASF) under the Apache License, Version 2.0. */ + +export type AgentSchedule = { + id: number; + name: string; + instruction: string; + cronExpression: string; + enabled: boolean; + sessionId: number | null; + receiverIds: number[]; + templateId: number | null; + createdFromSessionUid: string | null; + lastTriggerAt: number | null; + nextTriggerAt: number | null; + creator: string | null; + modifier: string | null; + gmtCreate: string | null; + gmtUpdate: string | null; +}; + +export type AgentScheduleDraft = Pick< + AgentSchedule, + 'name' | 'instruction' | 'cronExpression' | 'enabled' | 'receiverIds' | 'templateId' +>; + +export type AgentScheduleOption = { id: number; name: string; type: number }; +export type AgentScheduleOptions = { + receivers: AgentScheduleOption[]; + templates: AgentScheduleOption[]; +}; + +export type AgentScheduleTranscriptEntry = { + id: number; + sequence: number; + role: 'user' | 'assistant'; + text: string; + createdAt: string | null; +}; + +export type AgentScheduleTranscriptPage = { + entries: AgentScheduleTranscriptEntry[]; + pageIndex: number; + hasEarlier: boolean; +}; + +export type AgentSchedulePage = { + content: AgentSchedule[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +}; diff --git a/web-app/src/features/ai-workspace/model/agent-schedule-view-model.ts b/web-app/src/features/ai-workspace/model/agent-schedule-view-model.ts new file mode 100644 index 0000000000..1c24bc3ae0 --- /dev/null +++ b/web-app/src/features/ai-workspace/model/agent-schedule-view-model.ts @@ -0,0 +1,60 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import type { + AgentSchedule, + AgentScheduleDraft, + AgentScheduleOptions, + AgentScheduleTranscriptEntry +} from './agent-schedule-contract'; + +export type AgentScheduleListState = { + kind: 'loading' | 'ready' | 'empty' | 'error'; + items: AgentSchedule[]; + total: number; + pageIndex: number; + pageSize: number; +}; + +export type AgentScheduleEditorState = { + mode: 'create' | 'edit'; + scheduleId: number | null; + draft: AgentScheduleDraft; +}; + +export type AgentScheduleTranscriptState = { + open: boolean; + schedule: AgentSchedule | null; + kind: 'idle' | 'loading' | 'ready' | 'empty' | 'error'; + entries: AgentScheduleTranscriptEntry[]; + pageIndex: number; + hasEarlier: boolean; + loadingEarlier: boolean; +}; + +export type AgentScheduleViewModel = { + list: AgentScheduleListState; + options: AgentScheduleOptions; + editor: AgentScheduleEditorState | null; + transcript: AgentScheduleTranscriptState; + busy: string | null; + mutationFailed: boolean; + actions: { + reload: () => Promise<void>; + setPage: (pageIndex: number, pageSize: number) => Promise<void>; + openCreate: () => void; + openEdit: (schedule: AgentSchedule) => void; + closeEditor: () => void; + updateDraft: (patch: Partial<AgentScheduleDraft>) => void; + save: () => Promise<void>; + toggle: (scheduleId: number, enabled: boolean) => Promise<void>; + run: (scheduleId: number) => Promise<void>; + delete: (scheduleId: number) => Promise<void>; + openTranscript: (schedule: AgentSchedule) => Promise<void>; + loadEarlierTranscript: () => Promise<void>; + closeTranscript: () => void; + }; +}; diff --git a/web-app/src/features/ai-workspace/pages/agent-schedule-page.test.tsx b/web-app/src/features/ai-workspace/pages/agent-schedule-page.test.tsx new file mode 100644 index 0000000000..762f64d60d --- /dev/null +++ b/web-app/src/features/ai-workspace/pages/agent-schedule-page.test.tsx @@ -0,0 +1,167 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { App } from 'antd'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const controller = vi.hoisted(() => ({ value: scheduleController() })); +vi.mock('../controller/use-agent-schedule-controller', () => ({ + useAgentScheduleController: () => controller.value +})); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +import { AgentSchedulePage } from './agent-schedule-page'; + +describe('AgentSchedulePage', () => { + beforeEach(() => { + controller.value = scheduleController(); + }); + afterEach(cleanup); + + it('renders the system schedule and delegates every lifecycle action', () => { + renderPage(); + + expect(screen.getByRole('heading', { name: 'aiSchedules.title' })).toBeInTheDocument(); + const row = screen.getByText('Daily database review').closest('tr'); + expect(row).not.toBeNull(); + fireEvent.click(within(row!).getByRole('switch')); + fireEvent.click(within(row!).getByRole('button', { name: 'aiSchedules.actions.runNow' })); + fireEvent.click(within(row!).getByRole('button', { name: 'aiSchedules.actions.transcript' })); + fireEvent.click(within(row!).getByRole('button', { name: 'aiSchedules.actions.edit' })); + fireEvent.click(within(row!).getByRole('button', { name: 'aiSchedules.actions.delete' })); + fireEvent.click(screen.getByRole('button', { name: 'OK' })); + + expect(controller.value.actions.toggle).toHaveBeenCalledWith(7, false); + expect(controller.value.actions.run).toHaveBeenCalledWith(7); + expect(controller.value.actions.openTranscript).toHaveBeenCalledWith(controller.value.list.items[0]); + expect(controller.value.actions.openEdit).toHaveBeenCalledWith(controller.value.list.items[0]); + expect(controller.value.actions.delete).toHaveBeenCalledWith(7); + }); + + it('shows the complete editor and delegates the owned draft', () => { + controller.value = scheduleController({ + editor: { + mode: 'create', + scheduleId: null, + draft: { + name: 'Availability review', + instruction: 'Review production availability and notify the on-call receiver.', + cronExpression: '0 0 9 * * *', + enabled: true, + receiverIds: [11], + templateId: null + } + } + }); + renderPage(); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByDisplayValue('Availability review')).toBeInTheDocument(); + expect(within(dialog).getByDisplayValue('0 0 9 * * *')).toBeInTheDocument(); + expect(within(dialog).getAllByRole('combobox')).toHaveLength(2); + fireEvent.click(within(dialog).getByRole('button', { name: 'aiSchedules.editor.save' })); + + expect(controller.value.actions.save).toHaveBeenCalledOnce(); + }); + + it('keeps loading, empty, and unavailable states distinct', () => { + controller.value = scheduleController({ + list: { kind: 'loading', items: [], total: 0, pageIndex: 0, pageSize: 20 } + }); + const view = renderPage(); + expect(document.querySelector('[data-state="loading"]')).toHaveTextContent('aiSchedules.states.loading'); + + controller.value = scheduleController({ list: { kind: 'empty', items: [], total: 0, pageIndex: 0, pageSize: 20 } }); + view.rerender(shell()); + expect(document.querySelector('[data-state="empty"]')).toHaveTextContent('aiSchedules.states.empty'); + + controller.value = scheduleController({ list: { kind: 'error', items: [], total: 0, pageIndex: 0, pageSize: 20 } }); + view.rerender(shell()); + expect(document.querySelector('[data-state="unavailable"]')).toHaveTextContent('aiSchedules.states.unavailable'); + }); + + it('keeps a failed first create visible while the schedule list is empty', () => { + controller.value = scheduleController({ + list: { kind: 'empty', items: [], total: 0, pageIndex: 0, pageSize: 20 }, + mutationFailed: true + }); + + renderPage(); + + expect(screen.getByText('aiSchedules.states.mutationFailed')).toBeInTheDocument(); + }); +}); + +function renderPage() { + return render(shell()); +} + +function shell() { + return ( + <MemoryRouter> + <App> + <AgentSchedulePage /> + </App> + </MemoryRouter> + ); +} + +function scheduleController(overrides: Record<string, unknown> = {}) { + const schedule = { + id: 7, + name: 'Daily database review', + instruction: 'Review database saturation.', + cronExpression: '0 0 9 * * *', + enabled: true, + sessionId: 9, + receiverIds: [11], + templateId: 21, + createdFromSessionUid: null, + lastTriggerAt: null, + nextTriggerAt: 1_786_678_800_000, + creator: 'admin', + modifier: 'admin', + gmtCreate: null, + gmtUpdate: null + }; + return { + list: { kind: 'ready', items: [schedule], total: 1, pageIndex: 0, pageSize: 20 }, + options: { + receivers: [{ id: 11, name: 'On-call email', type: 1 }], + templates: [{ id: 21, name: 'AI review', type: 1 }] + }, + editor: null, + transcript: { + open: false, + schedule: null, + kind: 'idle', + entries: [], + pageIndex: 0, + hasEarlier: false, + loadingEarlier: false + }, + busy: null, + mutationFailed: false, + actions: { + reload: vi.fn(), + setPage: vi.fn(), + openCreate: vi.fn(), + openEdit: vi.fn(), + closeEditor: vi.fn(), + updateDraft: vi.fn(), + save: vi.fn(), + toggle: vi.fn(), + run: vi.fn(), + delete: vi.fn(), + openTranscript: vi.fn(), + loadEarlierTranscript: vi.fn(), + closeTranscript: vi.fn() + }, + ...overrides + }; +} diff --git a/web-app/src/features/ai-workspace/pages/agent-schedule-page.tsx b/web-app/src/features/ai-workspace/pages/agent-schedule-page.tsx new file mode 100644 index 0000000000..e490ed5833 --- /dev/null +++ b/web-app/src/features/ai-workspace/pages/agent-schedule-page.tsx @@ -0,0 +1,43 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +import { Button, Space } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { applicationRoutePaths } from '@/shared/navigation/app-paths'; +import { OperationalPage, OperationalPageHeader, OperationalResultRegion } from '@/shared/operational-page'; + +import { AgentScheduleView } from '../components/agent-schedule-view'; +import { useAgentScheduleController } from '../controller/use-agent-schedule-controller'; + +export function AgentSchedulePage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const controller = useAgentScheduleController(); + return ( + <OperationalPage> + <OperationalPageHeader + title={t('aiSchedules.title')} + description={t('aiSchedules.description')} + actions={ + <Space> + <Button onClick={() => void navigate(applicationRoutePaths.aiWorkspace)}>{t('common.back')}</Button> + <Button disabled={controller.busy !== null} onClick={() => void controller.actions.reload()}> + {t('common.refresh')} + </Button> + <Button type="primary" disabled={controller.busy !== null} onClick={controller.actions.openCreate}> + {t('aiSchedules.create')} + </Button> + </Space> + } + /> + <OperationalResultRegion> + <AgentScheduleView controller={controller} /> + </OperationalResultRegion> + </OperationalPage> + ); +} diff --git a/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx b/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx index d7c726ab76..380c15adc2 100644 --- a/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx +++ b/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx @@ -3,8 +3,11 @@ import { AgentProviderDialog } from '../components/agent-provider-dialog'; import { AgentWorkspaceView } from '../components/agent-workspace-view'; import { useAgentWorkspacePageController } from '../controller/use-agent-workspace-page-controller'; +import { applicationRoutePaths } from '@/shared/navigation/app-paths'; +import { useNavigate } from 'react-router-dom'; export function AgentWorkspacePage() { + const navigate = useNavigate(); const controller = useAgentWorkspacePageController(); return ( <> @@ -12,6 +15,7 @@ export function AgentWorkspacePage() { controller={controller.workspace} isAdmin={controller.isAdmin} onOpenProviders={controller.openProviders} + onOpenSchedules={() => void navigate(applicationRoutePaths.aiSchedules)} /> {controller.isAdmin ? ( <AgentProviderDialog diff --git a/web-app/src/shared/navigation/app-paths.ts b/web-app/src/shared/navigation/app-paths.ts index d68a71a628..4fa0453cbf 100644 --- a/web-app/src/shared/navigation/app-paths.ts +++ b/web-app/src/shared/navigation/app-paths.ts @@ -26,6 +26,7 @@ const entityListPath = '/entities'; export const applicationRoutePaths = { dashboard: defaultAuthenticatedPath, aiWorkspace: '/ai', + aiSchedules: '/ai/schedules', instrumentation: '/observability/integration', topology: '/topology', explore: '/explore', --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
