diff --git a/heicode/web/default/dist/index.html b/heicode/web/default/dist/index.html index da4ba19e..39c524f8 100644 --- a/heicode/web/default/dist/index.html +++ b/heicode/web/default/dist/index.html @@ -20,7 +20,7 @@ - +
diff --git a/heicode/web/default/src/features/agent-status/agent-status-page.tsx b/heicode/web/default/src/features/agent-status/agent-status-page.tsx new file mode 100644 index 00000000..28a64d90 --- /dev/null +++ b/heicode/web/default/src/features/agent-status/agent-status-page.tsx @@ -0,0 +1,267 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { Activity, Copy, Database, GitBranch, RefreshCw, Server, X } from 'lucide-react' +import { toast } from 'sonner' +import { api } from '@/lib/api' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' + +type AgentItem = { + agent_id: string + template_id: string + subdomain: string + binding_ids: number[] + status: string + runtime_id: string + created_at: string + updated_at: string +} +type TemplateItem = { template_id: string; name: string } +type ResourceItem = { id: number; name: string; resource_type: string } + +const RESOURCE_ICON: Record = { + git: GitBranch, + database: Database, + blob: Database, + vm: Server, +} + +function statusLabel(s: string): { text: string; cls: string } { + const v = (s || '').toLowerCase() + if (['running', 'active', 'ready'].includes(v)) + return { text: '运行中', cls: 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30' } + if (['pending', 'starting', 'provisioning'].includes(v)) + return { text: '启动中', cls: 'bg-amber-500/15 text-amber-300 ring-amber-500/30' } + if (['stopped'].includes(v)) + return { text: '已停止', cls: 'bg-muted/40 text-muted-foreground ring-border' } + if (['failed', 'error', 'unhealthy', 'crashed'].includes(v)) + return { text: '异常', cls: 'bg-rose-500/15 text-rose-300 ring-rose-500/30' } + return { text: s || '—', cls: 'bg-amber-500/15 text-amber-300 ring-amber-500/30' } +} + +async function listAgents(): Promise { + const res = await api.get<{ data?: { items?: AgentItem[] } }>('/api/heicode/agents') + return res.data?.data?.items ?? [] +} +async function listTemplates(): Promise { + const res = await api.get<{ data?: { items?: TemplateItem[] } }>('/api/heicode/agent-templates') + return res.data?.data?.items ?? [] +} +async function listResources(): Promise { + const res = await api.get<{ data?: { items?: ResourceItem[] } }>('/api/resources/?status=active') + return res.data?.data?.items ?? [] +} + +export function AgentStatusPage() { + const qc = useQueryClient() + const [selected, setSelected] = useState(null) + + const agentsQ = useQuery({ queryKey: ['agent-status', 'agents'], queryFn: listAgents, refetchInterval: 15_000, retry: false }) + const templatesQ = useQuery({ queryKey: ['agent-templates'], queryFn: listTemplates, retry: false }) + const resourcesQ = useQuery({ queryKey: ['resources', 'active'], queryFn: listResources, retry: false }) + + const templateName = (key: string) => + (templatesQ.data ?? []).find((t) => t.template_id === key)?.name ?? key + const resourceName = (id: number) => + (resourcesQ.data ?? []).find((r) => r.id === id)?.name ?? `资源#${id}` + const resourceType = (id: number) => + (resourcesQ.data ?? []).find((r) => r.id === id)?.resource_type ?? '' + + const agents = agentsQ.data ?? [] + const counts = useMemo(() => { + let running = 0, pending = 0, other = 0 + for (const a of agents) { + const v = (a.status || '').toLowerCase() + if (['running', 'active', 'ready'].includes(v)) running++ + else if (['pending', 'starting', 'provisioning'].includes(v)) pending++ + else other++ + } + return { running, pending, other, total: agents.length } + }, [agents]) + + const copy = async (text: string) => { + if (!text) return + try { + await navigator.clipboard.writeText(text) + toast.success('已复制') + } catch { + toast.error('复制失败,请手动复制') + } + } + + return ( + <> +
+
+

+ Heicode Manager +

+

+ 运行状态 +

+

+ 查看你部署的 Agent 当前运行情况。状态每 15 秒自动刷新。 +

+
+ + + + +
+
+ +
+
+

Agent 列表

+ +
+ + {agentsQ.isLoading ? ( +
{[0, 1].map((i) => )}
+ ) : agents.length === 0 ? ( +
+ 还没有部署 Agent。去 + 部署 Sub Agent + 部署一个。 +
+ ) : ( +
+ {agents.map((a, idx) => { + const st = statusLabel(a.status) + return ( + + ) + })} +
+ )} +
+
+ + {selected && ( + setSelected(null)} + onCopy={copy} + /> + )} + + ) +} + +function Stat({ label, value, tone }: { label: string; value: number; tone?: 'emerald' | 'amber' }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function DetailPanel({ + agent, + templateName, + resourceName, + resourceType, + onClose, + onCopy, +}: { + agent: AgentItem + templateName: string + resourceName: (id: number) => string + resourceType: (id: number) => string + onClose: () => void + onCopy: (t: string) => void +}) { + // Live status pull from AM for this agent. + const liveQ = useQuery({ + queryKey: ['agent-status', 'live', agent.agent_id], + queryFn: async () => { + const res = await api.get<{ data?: { status?: string } }>(`/api/heicode/agents/${agent.agent_id}/status`) + return res.data?.data?.status ?? agent.status + }, + refetchInterval: 10_000, + retry: false, + }) + const st = statusLabel(liveQ.data ?? agent.status) + + return ( +
+
+
+

{templateName}

+

{agent.agent_id}

+
+ +
+ +
+ + {st.text} + {liveQ.isFetching ? '刷新中…' : '每 10 秒刷新'} + + +
+ {agent.subdomain || '地址生成中…'} + +
+
+ + {(agent.binding_ids?.length ?? 0) === 0 ? ( + 未挂载资源 + ) : ( +
+ {agent.binding_ids.map((id) => { + const Icon = RESOURCE_ICON[resourceType(id)] ?? Database + return ( + + {resourceName(id)} + + ) + })} +
+ )} +
+ {agent.runtime_id || '—'} + {agent.created_at} + {agent.updated_at} +
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

{label}

+
{children}
+
+ ) +} diff --git a/heicode/web/default/src/hooks/use-sidebar-data.ts b/heicode/web/default/src/hooks/use-sidebar-data.ts index 32c32a7e..3dae55df 100644 --- a/heicode/web/default/src/hooks/use-sidebar-data.ts +++ b/heicode/web/default/src/hooks/use-sidebar-data.ts @@ -1,4 +1,5 @@ import { + Activity, Bot, Command, Download, @@ -57,6 +58,11 @@ export function useSidebarData(): SidebarData { url: '/deploy-agent', icon: Bot, }, + { + title: t('Agent status'), + url: '/agent-status', + icon: Activity, + }, ], }, diff --git a/heicode/web/default/src/i18n/locales/zh.json b/heicode/web/default/src/i18n/locales/zh.json index 66942ffd..0cdfa95d 100644 --- a/heicode/web/default/src/i18n/locales/zh.json +++ b/heicode/web/default/src/i18n/locales/zh.json @@ -213,6 +213,7 @@ "All statuses": "全部状态", "Agent runs": "运行总览", "Deploy sub agent": "部署 Sub Agent", + "Agent status": "运行状态", "Resource binding": "资源绑定", "Bind your git repos / VMs / databases / blob. Credentials go straight to Azure Key Vault — only a secret_ref is stored.": "绑定你的 git 仓库 / 虚拟机 / 数据库 / 对象存储。凭据直接写入 Azure Key Vault,本地只保存 secret_ref。", "No resources bound yet": "还没有绑定任何资源", diff --git a/heicode/web/default/src/routeTree.gen.ts b/heicode/web/default/src/routeTree.gen.ts index 0fe8ce05..2b577bcf 100644 --- a/heicode/web/default/src/routeTree.gen.ts +++ b/heicode/web/default/src/routeTree.gen.ts @@ -49,6 +49,7 @@ import { Route as AuthenticatedDeployAgentIndexRouteImport } from './routes/_aut import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index' import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index' import { Route as AuthenticatedAvailableModelsIndexRouteImport } from './routes/_authenticated/available-models/index' +import { Route as AuthenticatedAgentStatusIndexRouteImport } from './routes/_authenticated/agent-status/index' import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section' import { Route as AuthenticatedModelsSectionRouteImport } from './routes/_authenticated/models/$section' import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error' @@ -284,6 +285,12 @@ const AuthenticatedAvailableModelsIndexRoute = path: '/available-models/', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedAgentStatusIndexRoute = + AuthenticatedAgentStatusIndexRouteImport.update({ + id: '/agent-status/', + path: '/agent-status/', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedUsageLogsSectionRoute = AuthenticatedUsageLogsSectionRouteImport.update({ id: '/usage-logs/$section', @@ -430,6 +437,7 @@ export interface FileRoutesByFullPath { '/errors/$error': typeof AuthenticatedErrorsErrorRoute '/models/$section': typeof AuthenticatedModelsSectionRoute '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute + '/agent-status/': typeof AuthenticatedAgentStatusIndexRoute '/available-models/': typeof AuthenticatedAvailableModelsIndexRoute '/channels/': typeof AuthenticatedChannelsIndexRoute '/dashboard/': typeof AuthenticatedDashboardIndexRoute @@ -489,6 +497,7 @@ export interface FileRoutesByTo { '/errors/$error': typeof AuthenticatedErrorsErrorRoute '/models/$section': typeof AuthenticatedModelsSectionRoute '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute + '/agent-status': typeof AuthenticatedAgentStatusIndexRoute '/available-models': typeof AuthenticatedAvailableModelsIndexRoute '/channels': typeof AuthenticatedChannelsIndexRoute '/dashboard': typeof AuthenticatedDashboardIndexRoute @@ -552,6 +561,7 @@ export interface FileRoutesById { '/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute '/_authenticated/models/$section': typeof AuthenticatedModelsSectionRoute '/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute + '/_authenticated/agent-status/': typeof AuthenticatedAgentStatusIndexRoute '/_authenticated/available-models/': typeof AuthenticatedAvailableModelsIndexRoute '/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute '/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute @@ -614,6 +624,7 @@ export interface FileRouteTypes { | '/errors/$error' | '/models/$section' | '/usage-logs/$section' + | '/agent-status/' | '/available-models/' | '/channels/' | '/dashboard/' @@ -673,6 +684,7 @@ export interface FileRouteTypes { | '/errors/$error' | '/models/$section' | '/usage-logs/$section' + | '/agent-status' | '/available-models' | '/channels' | '/dashboard' @@ -735,6 +747,7 @@ export interface FileRouteTypes { | '/_authenticated/errors/$error' | '/_authenticated/models/$section' | '/_authenticated/usage-logs/$section' + | '/_authenticated/agent-status/' | '/_authenticated/available-models/' | '/_authenticated/channels/' | '/_authenticated/dashboard/' @@ -1069,6 +1082,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedAvailableModelsIndexRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/agent-status/': { + id: '/_authenticated/agent-status/' + path: '/agent-status' + fullPath: '/agent-status/' + preLoaderRoute: typeof AuthenticatedAgentStatusIndexRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/usage-logs/$section': { id: '/_authenticated/usage-logs/$section' path: '/usage-logs/$section' @@ -1301,6 +1321,7 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute AuthenticatedModelsSectionRoute: typeof AuthenticatedModelsSectionRoute AuthenticatedUsageLogsSectionRoute: typeof AuthenticatedUsageLogsSectionRoute + AuthenticatedAgentStatusIndexRoute: typeof AuthenticatedAgentStatusIndexRoute AuthenticatedAvailableModelsIndexRoute: typeof AuthenticatedAvailableModelsIndexRoute AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute @@ -1328,6 +1349,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute, AuthenticatedModelsSectionRoute: AuthenticatedModelsSectionRoute, AuthenticatedUsageLogsSectionRoute: AuthenticatedUsageLogsSectionRoute, + AuthenticatedAgentStatusIndexRoute: AuthenticatedAgentStatusIndexRoute, AuthenticatedAvailableModelsIndexRoute: AuthenticatedAvailableModelsIndexRoute, AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute, diff --git a/heicode/web/default/src/routes/_authenticated/agent-status/index.tsx b/heicode/web/default/src/routes/_authenticated/agent-status/index.tsx new file mode 100644 index 00000000..fce26ff9 --- /dev/null +++ b/heicode/web/default/src/routes/_authenticated/agent-status/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { AgentStatusPage } from '@/features/agent-status/agent-status-page' + +export const Route = createFileRoute('/_authenticated/agent-status/')({ + component: AgentStatusPage, +})