合并主线并解决应用壳层冲突
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
commit
bf8d048b67
@ -21,4 +21,11 @@ describe("Teamserver 工作区", () => {
|
||||
expect(screen.getByText("NW-042")).toBeInTheDocument();
|
||||
expect(screen.getByText("10.42.8.17")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("通过拆分后的侧边栏进入主线新增的连接中心", () => {
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "连接中心" }));
|
||||
expect(screen.getByRole("heading", { name: "连接中心" })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Session 表格")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useReducer, useState } from "react";
|
||||
import { ConnectionCenter, MemoryMockConnectionAdapter } from "./features/connections";
|
||||
import { SessionQueryIntent, SessionTable } from "./SessionTable";
|
||||
import { demoSessions } from "./protocol/fixtures";
|
||||
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
||||
@ -14,18 +15,21 @@ import {
|
||||
|
||||
const fixtureNow = new Date("2026-08-03T19:12:02Z");
|
||||
const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow));
|
||||
const connectionAdapter = new MemoryMockConnectionAdapter();
|
||||
|
||||
export function App() {
|
||||
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
|
||||
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
|
||||
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
|
||||
const [view, setView] = useState<"sessions" | "connections">("sessions");
|
||||
const server = selectActiveWorkspace(workspace);
|
||||
const activeTab = selectActiveTab(server);
|
||||
const tableState = { ...tableStates[server.id], filter: server.filter };
|
||||
|
||||
return (
|
||||
<WorkspaceLayout servers={workspace.serverOrder.map((id) => workspace.servers[id])} server={server} activeTab={activeTab} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })}>
|
||||
<WorkspaceLayout servers={workspace.serverOrder.map((id) => workspace.servers[id])} server={server} activeTab={activeTab} activePage={view === "connections" ? "连接中心" : activeTab.title} onNavigate={(page) => setView(page === "连接中心" ? "connections" : "sessions")} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })}>
|
||||
<article className="content">
|
||||
{view === "connections" ? <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> : <>
|
||||
<div className="content-heading">
|
||||
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
||||
<div className="heading-actions"><Button>导出视图</Button><Button intent="primary"><Icon name="add" />新建操作</Button></div>
|
||||
@ -35,6 +39,7 @@ export function App() {
|
||||
setTableStates((current) => updateSessionTableState(current, server.id, state));
|
||||
}} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />
|
||||
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
|
||||
</>}
|
||||
</article>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
|
||||
20
src/features/activity/activity.css
Normal file
20
src/features/activity/activity.css
Normal file
@ -0,0 +1,20 @@
|
||||
.activity-status { display:inline-flex; align-items:center; gap:6px; font-size:12px; white-space:nowrap }
|
||||
.activity-status__mark { width:8px; height:8px; border:2px solid currentColor; border-radius:50% }
|
||||
.activity-status--running .activity-status__mark { border-radius:2px }
|
||||
.activity-status--succeeded { color:#277a4b }.activity-status--failed,.activity-status--cancelled { color:#a23d3d }
|
||||
.activity-list { display:grid; border:1px solid #d9dde5; border-radius:8px; overflow:hidden }
|
||||
.activity-list__row { min-height:38px; display:grid; grid-template-columns:minmax(150px,1fr) 100px 90px; align-items:center; gap:12px; padding:0 12px; border:0; border-bottom:1px solid #e6e8ed; background:#fff; text-align:left; color:inherit }
|
||||
.activity-list__row:hover,.activity-list__row[aria-pressed="true"] { background:#f1f4f9 }
|
||||
.activity-empty { padding:24px; color:#667085; text-align:center; border:1px dashed #cfd4dc; border-radius:8px }
|
||||
.activity-detail { display:grid; gap:16px }.activity-detail>header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start }.activity-detail h2 { margin:0;font-size:16px }.activity-detail dl,.activity-dialog dl { display:grid; grid-template-columns:120px 1fr; margin:0; gap:8px 12px }.activity-detail dt,.activity-dialog dt { color:#667085 }.activity-detail dd,.activity-dialog dd { margin:0; overflow-wrap:anywhere }
|
||||
.activity-timeline { display:flex; padding:0; list-style:none; gap:8px }.activity-timeline li { display:grid; border-left:3px solid #8793a8; padding-left:10px; min-width:130px }.activity-timeline time { color:#667085;font-size:11px }
|
||||
.activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#f5f6f8 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 }
|
||||
.activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#f5f6f8; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace }
|
||||
.activity-danger { color:#a12323; border-color:#cf7777 }
|
||||
.activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#1118 }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:white;border-radius:10px;padding:20px;box-shadow:0 18px 60px #0004 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px }
|
||||
.activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px }
|
||||
.activity-events { min-height:200px;border:1px solid #d9dde5;border-radius:8px;overflow:hidden }.activity-events__feed { max-height:420px;overflow:auto }.activity-event { width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#fff;border:0;border-bottom:1px solid #e6e8ed;color:inherit }.activity-event:hover { background:#f5f7fa }.activity-event span { color:#667085 }.activity-event--new { background:#edf5ff }
|
||||
.activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #e2b75e;border-radius:7px;background:#fff8e5;color:#654b14 }
|
||||
button,input,select { font:inherit }button { cursor:pointer;border:1px solid #bbc2ce;border-radius:5px;padding:5px 9px;background:#fff }button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:3px solid #477ee8;outline-offset:2px }button:disabled { opacity:.5;cursor:not-allowed }
|
||||
@media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } }
|
||||
@media (prefers-reduced-motion:reduce) { .activity-event--new { animation:none } }
|
||||
35
src/features/activity/components.test.tsx
Normal file
35
src/features/activity/components.test.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model";
|
||||
import { CancelTaskDialog, ReconnectBanner, TaskOutput } from "./components";
|
||||
|
||||
const task: Task = {
|
||||
server_id: "server-a" as ServerId, project_id: "project-a" as ProjectId, session_id: "session-a" as SessionId,
|
||||
task_id: "task-a" as TaskId, capability_id: "operate" as CapabilityId, state: "RUNNING", cancellable: true,
|
||||
created_at: "2026-01-01T00:00:00Z" as Timestamp, updated_at: "2026-01-01T00:00:01Z" as Timestamp,
|
||||
};
|
||||
|
||||
describe("activity components", () => {
|
||||
it("取消确认展示固定上下文并要求显式确认", () => {
|
||||
const confirm = vi.fn();
|
||||
render(<CancelTaskDialog task={task} open onConfirm={confirm} onClose={vi.fn()} />);
|
||||
expect(screen.getByText("server-a")).toBeInTheDocument();
|
||||
expect(screen.getByText("project-a")).toBeInTheDocument();
|
||||
expect(screen.getByText("session-a")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认取消" }));
|
||||
expect(confirm).toHaveBeenCalledWith(task);
|
||||
});
|
||||
|
||||
it("输出支持暂停和恢复自动跟随且显示脱敏状态", () => {
|
||||
const changed = vi.fn();
|
||||
render(<TaskOutput follow entries={[{ id: "1", task_id: task.task_id, timestamp: task.updated_at, text: "token=[已隐藏]", redacted: true }]} onFollowChange={changed} onCopy={vi.fn()} />);
|
||||
expect(screen.getAllByText(/已脱敏/).length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "暂停跟随" }));
|
||||
expect(changed).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("恢复缺口使用告警语义呈现", () => {
|
||||
render(<ReconnectBanner phase="streaming" gap={{ message: "检测到事件缺口" }} />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("检测到事件缺口");
|
||||
});
|
||||
});
|
||||
63
src/features/activity/components.tsx
Normal file
63
src/features/activity/components.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import type { Event, Task } from "../../protocol/model";
|
||||
import { canCancelTask, taskDuration } from "./state";
|
||||
import type { EventFiltersValue, ResumeGap, StreamPhase, TaskOutputEntry } from "./types";
|
||||
import "./activity.css";
|
||||
|
||||
const taskLabels: Readonly<Record<string, string>> = {
|
||||
PENDING: "等待中", RUNNING: "运行中", SUCCEEDED: "已成功", FAILED: "失败", CANCELLED: "已取消",
|
||||
};
|
||||
|
||||
export function TaskStatus({ state }: { readonly state: Task["state"] }) {
|
||||
return <span className={`activity-status activity-status--${state.toLowerCase()}`}><span aria-hidden="true" className="activity-status__mark" />{taskLabels[state] ?? state}</span>;
|
||||
}
|
||||
|
||||
export function TaskList({ tasks, selectedTaskId, onSelect, loading = false }: { readonly tasks: readonly Task[]; readonly selectedTaskId?: string; readonly onSelect?: (task: Task) => void; readonly loading?: boolean }) {
|
||||
if (loading) return <div className="activity-empty" role="status">正在加载任务…</div>;
|
||||
if (!tasks.length) return <div className="activity-empty">暂无任务</div>;
|
||||
return <div className="activity-list" aria-label="任务列表">{tasks.map((task) => <button key={task.task_id} className="activity-list__row" aria-pressed={task.task_id === selectedTaskId} onClick={() => onSelect?.(task)}><span>{task.capability_id}</span><TaskStatus state={task.state} /><time>{taskDuration(task)}</time></button>)}</div>;
|
||||
}
|
||||
|
||||
export function TaskTimeline({ task }: { readonly task: Task }) {
|
||||
return <ol className="activity-timeline"><li><strong>已提交</strong><time>{task.created_at}</time></li>{task.state !== "PENDING" && <li><strong>{taskLabels[task.state] ?? task.state}</strong><time>{task.updated_at}</time></li>}</ol>;
|
||||
}
|
||||
|
||||
export function TaskOutput({ entries, follow, onFollowChange, onCopy }: { readonly entries: readonly TaskOutputEntry[]; readonly follow: boolean; readonly onFollowChange?: (follow: boolean) => void; readonly onCopy?: (text: string) => void }) {
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (follow && outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; }, [entries, follow]);
|
||||
const text = entries.map((entry) => entry.text).join("\n");
|
||||
return <section className="activity-output" aria-label="任务输出"><header><span>{entries.some((entry) => entry.redacted) ? "已脱敏输出" : "标准输出"}</span><button onClick={() => (onCopy ? onCopy(text) : void navigator.clipboard?.writeText(text))}>复制</button><button aria-pressed={follow} onClick={() => onFollowChange?.(!follow)}>{follow ? "暂停跟随" : "继续跟随"}</button></header><div ref={outputRef} className="activity-output__body" tabIndex={0}>{entries.length ? entries.map((entry) => <div key={entry.id}><time>{entry.timestamp}</time> {entry.text}{entry.redacted && <span className="activity-redacted"> 已脱敏</span>}</div>) : <span>尚无输出</span>}</div></section>;
|
||||
}
|
||||
|
||||
export function TaskDetail({ task, outputs = [], followOutput = true, onFollowOutputChange, onCancel }: { readonly task?: Task; readonly outputs?: readonly TaskOutputEntry[]; readonly followOutput?: boolean; readonly onFollowOutputChange?: (follow: boolean) => void; readonly onCancel?: (task: Task) => void }) {
|
||||
if (!task) return <div className="activity-empty">选择任务以查看详情</div>;
|
||||
return <article className="activity-detail"><header><div><h2>{task.capability_id}</h2><small>{task.task_id}</small></div><TaskStatus state={task.state} /></header><dl><dt>持续时间</dt><dd>{taskDuration(task)}</dd><dt>固定上下文</dt><dd>{task.server_id} / {task.project_id} / {task.session_id}</dd></dl>{task.result !== undefined && <pre className="activity-result">{JSON.stringify(task.result, null, 2)}</pre>}<TaskTimeline task={task} /><TaskOutput entries={outputs} follow={followOutput} onFollowChange={onFollowOutputChange} /><button className="activity-danger" disabled={!canCancelTask(task)} onClick={() => onCancel?.(task)}>取消任务</button></article>;
|
||||
}
|
||||
|
||||
export function CancelTaskDialog({ task, open, busy = false, error, onConfirm, onClose }: { readonly task?: Task; readonly open: boolean; readonly busy?: boolean; readonly error?: string; readonly onConfirm: (task: Task) => void; readonly onClose: () => void }) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => { if (open) cancelRef.current?.focus(); }, [open]);
|
||||
if (!open || !task) return null;
|
||||
return <div className="activity-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}><div role="dialog" aria-modal="true" aria-labelledby="cancel-title" className="activity-dialog" onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}><h2 id="cancel-title">确认取消任务?</h2><p>断开连接不会取消任务;只有此次明确确认才会发送取消请求。</p><dl><dt>Teamserver</dt><dd>{task.server_id}</dd><dt>Project</dt><dd>{task.project_id}</dd><dt>Session</dt><dd>{task.session_id}</dd><dt>Task</dt><dd>{task.task_id}</dd></dl>{error && <p role="alert">{error}</p>}<footer><button ref={cancelRef} onClick={onClose}>返回</button><button className="activity-danger" disabled={busy || !canCancelTask(task)} onClick={() => onConfirm(task)}>{busy ? "正在取消…" : "确认取消"}</button></footer></div></div>;
|
||||
}
|
||||
|
||||
export function EventFilters({ value, knownTypes, onChange }: { readonly value: EventFiltersValue; readonly knownTypes: readonly string[]; readonly onChange: (value: EventFiltersValue) => void }) {
|
||||
return <form className="activity-filters" onSubmit={(event: FormEvent) => event.preventDefault()}><label>事件类型<select multiple value={[...value.types]} onChange={(event) => onChange({ ...value, types: Array.from(event.currentTarget.selectedOptions, (option) => option.value) })}>{knownTypes.map((type) => <option key={type}>{type}</option>)}</select></label><label>开始时间<input type="datetime-local" onChange={(event) => onChange({ ...value, from: event.currentTarget.value ? `${event.currentTarget.value}:00.000Z` as EventFiltersValue["from"] : undefined })} /></label><button type="button" onClick={() => onChange({ types: [] })}>清除筛选</button></form>;
|
||||
}
|
||||
|
||||
export function EventStream({ events, follow, onFollowChange, onSelect, loading = false }: { readonly events: readonly Event[]; readonly follow: boolean; readonly onFollowChange?: (follow: boolean) => void; readonly onSelect?: (event: Event) => void; readonly loading?: boolean }) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (follow && endRef.current?.parentElement) endRef.current.parentElement.scrollTop = endRef.current.parentElement.scrollHeight; }, [events, follow]);
|
||||
return <section className="activity-events"><header><strong>实时事件</strong><button aria-pressed={follow} onClick={() => onFollowChange?.(!follow)}>{follow ? "暂停跟随" : "继续跟随"}</button></header>{loading ? <div className="activity-empty">正在同步事件…</div> : !events.length ? <div className="activity-empty">暂无匹配事件</div> : <div className="activity-events__feed">{events.map((event, index) => <button className={index === events.length - 1 ? "activity-event activity-event--new" : "activity-event"} key={event.event_id} onClick={() => onSelect?.(event)}><time>{event.timestamp}</time><strong>{event.type}</strong><span>序号 {event.sequence ?? "—"} · 游标 {event.cursor}</span></button>)}<div ref={endRef} /></div>}</section>;
|
||||
}
|
||||
|
||||
export function EventDetail({ event }: { readonly event?: Event }) {
|
||||
if (!event) return <div className="activity-empty">选择事件以查看详情</div>;
|
||||
return <article className="activity-detail"><header><h2>{event.type}</h2><time>{event.timestamp}</time></header><dl><dt>事件 ID</dt><dd>{event.event_id}</dd><dt>序号 / 游标</dt><dd>{event.sequence ?? "—"} / {event.cursor}</dd><dt>上下文</dt><dd>{event.context.server_id} / {event.context.project_id ?? "—"} / {event.context.session_id ?? "—"}</dd></dl><pre className="activity-result">{JSON.stringify(event.payload, null, 2)}</pre></article>;
|
||||
}
|
||||
|
||||
export function ReconnectBanner({ phase, gap, onReconnect }: { readonly phase: StreamPhase; readonly gap?: ResumeGap; readonly onReconnect?: () => void }) {
|
||||
if (["idle", "streaming", "closed"].includes(phase) && !gap) return null;
|
||||
const messages: Readonly<Record<string, string>> = { loading: "正在建立事件流…", degraded: "事件流已降级,任务仍在服务端继续运行。", reconnecting: "连接中断,正在从最近游标恢复…", recovery_failed: "无法恢复事件流,请重新同步。" };
|
||||
return <div role={phase === "recovery_failed" || gap ? "alert" : "status"} className="activity-banner"><span>{gap?.message ?? messages[phase] ?? phase}</span>{(phase === "recovery_failed" || phase === "degraded") && <button onClick={onReconnect}>重新连接</button>}</div>;
|
||||
}
|
||||
4
src/features/activity/index.ts
Normal file
4
src/features/activity/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./state";
|
||||
export * from "./memoryAdapter";
|
||||
export * from "./components";
|
||||
30
src/features/activity/memoryAdapter.test.ts
Normal file
30
src/features/activity/memoryAdapter.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityId, Cursor, Event, EventId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model";
|
||||
import { MemoryActivityAdapter } from "./memoryAdapter";
|
||||
|
||||
const scope = { server_id: "s" as ServerId, project_id: "p" as ProjectId, session_id: "x" as SessionId };
|
||||
const event: Event = { event_id: "e" as EventId, type: "TASK_UPDATED", timestamp: "2026-01-01T00:00:00Z" as Timestamp, cursor: "current" as Cursor, context: scope, payload: {} };
|
||||
const task: Task = { ...scope, task_id: "t" as TaskId, capability_id: "c" as CapabilityId, state: "RUNNING", cancellable: true, created_at: event.timestamp, updated_at: event.timestamp };
|
||||
|
||||
describe("MemoryActivityAdapter", () => {
|
||||
it("从有效游标之后恢复标准事件", async () => {
|
||||
const received = vi.fn();
|
||||
new MemoryActivityAdapter([event]).subscribe(scope, undefined, received, vi.fn());
|
||||
await Promise.resolve();
|
||||
expect(received).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("游标失效时报告缺口但继续恢复", async () => {
|
||||
const phase = vi.fn();
|
||||
new MemoryActivityAdapter([event]).subscribe(scope, "expired" as Cursor, vi.fn(), phase);
|
||||
await Promise.resolve();
|
||||
expect(phase).toHaveBeenCalledWith("streaming", expect.objectContaining({ requested_cursor: "expired", resumed_cursor: "current" }));
|
||||
});
|
||||
|
||||
it("断开订阅不会取消任务,取消必须显式调用", async () => {
|
||||
const adapter = new MemoryActivityAdapter([event], [task]);
|
||||
adapter.subscribe(scope, undefined, vi.fn(), vi.fn()).close();
|
||||
await expect(adapter.cancelTask(task)).resolves.toMatchObject({ state: "CANCELLED", cancellable: false });
|
||||
await expect(adapter.cancelTask({ ...task, state: "SUCCEEDED" })).rejects.toThrow("不可取消");
|
||||
});
|
||||
});
|
||||
34
src/features/activity/memoryAdapter.ts
Normal file
34
src/features/activity/memoryAdapter.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Cursor, Event, Task, Timestamp } from "../../protocol/model";
|
||||
import type { ActivityAdapter, ActivityScope, ActivitySubscription, ResumeGap, StreamPhase } from "./types";
|
||||
|
||||
export class MemoryActivityAdapter implements ActivityAdapter {
|
||||
readonly #events: readonly Event[];
|
||||
readonly #tasks = new Map<string, Task>();
|
||||
|
||||
constructor(events: readonly Event[] = [], tasks: readonly Task[] = []) {
|
||||
this.#events = events;
|
||||
tasks.forEach((task) => this.#tasks.set(task.task_id, task));
|
||||
}
|
||||
|
||||
subscribe(scope: ActivityScope, cursor: Cursor | undefined, onEvent: (event: Event) => void, onPhase: (phase: StreamPhase, gap?: ResumeGap) => void): ActivitySubscription {
|
||||
let closed = false;
|
||||
const scoped = this.#events.filter((event) => event.context.server_id === scope.server_id && event.context.project_id === scope.project_id);
|
||||
const cursorIndex = cursor ? scoped.findIndex((event) => event.cursor === cursor) : -1;
|
||||
const gap = cursor && cursorIndex < 0 ? { requested_cursor: cursor, resumed_cursor: scoped[0]?.cursor, message: "事件游标已失效,期间事件可能缺失。" } : undefined;
|
||||
queueMicrotask(() => {
|
||||
if (closed) return;
|
||||
onPhase("streaming", gap);
|
||||
scoped.slice(cursorIndex + 1).forEach((event) => { if (!closed) onEvent(event); });
|
||||
});
|
||||
return { close: () => { closed = true; } };
|
||||
}
|
||||
|
||||
async cancelTask(task: Task): Promise<Task> {
|
||||
if (!task.cancellable || ["SUCCEEDED", "FAILED", "CANCELLED"].includes(task.state)) {
|
||||
throw new Error("该任务当前不可取消");
|
||||
}
|
||||
const cancelled = { ...task, state: "CANCELLED" as const, cancellable: false, updated_at: new Date().toISOString() as Timestamp };
|
||||
this.#tasks.set(task.task_id, cancelled);
|
||||
return cancelled;
|
||||
}
|
||||
}
|
||||
33
src/features/activity/state.test.ts
Normal file
33
src/features/activity/state.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CapabilityId, Cursor, EventId, ProjectId, ServerId, SessionId, Task, TaskId, Timestamp } from "../../protocol/model";
|
||||
import { activityReducer, canCancelTask, filterEvents, initialActivityState, isTaskTransitionAllowed } from "./state";
|
||||
|
||||
const ids = { server_id: "server-1" as ServerId, project_id: "project-1" as ProjectId, session_id: "session-1" as SessionId };
|
||||
const task = (state: Task["state"], cancellable = true): Task => ({ ...ids, task_id: "task-1" as TaskId, capability_id: "shell" as CapabilityId, state, cancellable, created_at: "2026-01-01T00:00:00Z" as Timestamp, updated_at: "2026-01-01T00:00:01Z" as Timestamp });
|
||||
const event = (id: string, cursor: string, type = "TASK_UPDATED") => ({ event_id: id as EventId, type, timestamp: "2026-01-01T00:00:00Z" as Timestamp, cursor: cursor as Cursor, sequence: 1, context: ids, payload: {} });
|
||||
|
||||
describe("activity state", () => {
|
||||
it("仅接受合法任务状态转换并保护终态", () => {
|
||||
expect(isTaskTransitionAllowed("PENDING", "RUNNING")).toBe(true);
|
||||
expect(isTaskTransitionAllowed("SUCCEEDED", "RUNNING")).toBe(false);
|
||||
const finished = activityReducer(initialActivityState, { type: "task_received", task: task("SUCCEEDED") });
|
||||
expect(activityReducer(finished, { type: "task_received", task: task("RUNNING") })).toBe(finished);
|
||||
});
|
||||
|
||||
it("只允许可取消的非终态任务取消", () => {
|
||||
expect(canCancelTask(task("RUNNING"))).toBe(true);
|
||||
expect(canCancelTask(task("RUNNING", false))).toBe(false);
|
||||
expect(canCancelTask(task("FAILED"))).toBe(false);
|
||||
});
|
||||
|
||||
it("记录最新事件游标、去重并保留恢复缺口", () => {
|
||||
const once = activityReducer(initialActivityState, { type: "event_received", event: event("e1", "c1") });
|
||||
expect(activityReducer(once, { type: "event_received", event: event("e1", "c1") }).events).toHaveLength(1);
|
||||
const resumed = activityReducer(once, { type: "resume_completed", cursor: "c3" as Cursor, gap: { requested_cursor: "c1" as Cursor, resumed_cursor: "c3" as Cursor, message: "缺口" } });
|
||||
expect(resumed).toMatchObject({ stream_phase: "streaming", last_cursor: "c3", gap: { message: "缺口" } });
|
||||
});
|
||||
|
||||
it("按类型和固定上下文筛选事件", () => {
|
||||
expect(filterEvents([event("e1", "c1"), event("e2", "c2", "TASK_OUTPUT")], { types: ["TASK_OUTPUT"], project_id: ids.project_id })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
66
src/features/activity/state.ts
Normal file
66
src/features/activity/state.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { Event, Task, TaskState } from "../../protocol/model";
|
||||
import type { ActivityAction, ActivityState, EventFiltersValue } from "./types";
|
||||
|
||||
export const initialActivityState: ActivityState = {
|
||||
tasks: [], outputs: {}, events: [], stream_phase: "idle",
|
||||
follow_events: true, follow_output: true,
|
||||
};
|
||||
|
||||
export const terminalTaskStates = new Set<string>(["SUCCEEDED", "FAILED", "CANCELLED"]);
|
||||
|
||||
export function canCancelTask(task: Task): boolean {
|
||||
return task.cancellable && !terminalTaskStates.has(task.state);
|
||||
}
|
||||
|
||||
export function isTaskTransitionAllowed(from: TaskState, to: TaskState): boolean {
|
||||
if (from === to) return true;
|
||||
if (terminalTaskStates.has(from)) return false;
|
||||
const allowed: Readonly<Record<string, readonly string[]>> = {
|
||||
PENDING: ["RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"],
|
||||
RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"],
|
||||
};
|
||||
return allowed[from]?.includes(to) ?? true;
|
||||
}
|
||||
|
||||
export function activityReducer(state: ActivityState, action: ActivityAction): ActivityState {
|
||||
switch (action.type) {
|
||||
case "task_received": {
|
||||
const previous = state.tasks.find((task) => task.task_id === action.task.task_id);
|
||||
if (previous && !isTaskTransitionAllowed(previous.state, action.task.state)) return state;
|
||||
return { ...state, tasks: [...state.tasks.filter((task) => task.task_id !== action.task.task_id), action.task] };
|
||||
}
|
||||
case "task_output_received": {
|
||||
const key = action.output.task_id as string;
|
||||
return { ...state, outputs: { ...state.outputs, [key]: [...(state.outputs[key] ?? []), action.output] } };
|
||||
}
|
||||
case "event_received":
|
||||
if (state.events.some((event) => event.event_id === action.event.event_id)) return state;
|
||||
return { ...state, events: [...state.events, action.event], last_cursor: action.event.cursor };
|
||||
case "stream_phase_changed":
|
||||
return { ...state, stream_phase: action.phase };
|
||||
case "resume_completed":
|
||||
return { ...state, stream_phase: "streaming", last_cursor: action.cursor, gap: action.gap };
|
||||
case "select_task": return { ...state, selected_task_id: action.task_id };
|
||||
case "select_event": return { ...state, selected_event_id: action.event_id };
|
||||
case "set_follow_events": return { ...state, follow_events: action.enabled };
|
||||
case "set_follow_output": return { ...state, follow_output: action.enabled };
|
||||
}
|
||||
}
|
||||
|
||||
export function filterEvents(events: readonly Event[], filters: EventFiltersValue): readonly Event[] {
|
||||
return events.filter((event) =>
|
||||
(!filters.types.length || filters.types.includes(event.type)) &&
|
||||
(!filters.project_id || event.context.project_id === filters.project_id) &&
|
||||
(!filters.session_id || event.context.session_id === filters.session_id) &&
|
||||
(!filters.from || event.timestamp >= filters.from) &&
|
||||
(!filters.to || event.timestamp <= filters.to)
|
||||
);
|
||||
}
|
||||
|
||||
export function taskDuration(task: Task, now = Date.now()): string {
|
||||
const start = Date.parse(task.created_at);
|
||||
const end = terminalTaskStates.has(task.state) ? Date.parse(task.updated_at) : now;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return "未知";
|
||||
const seconds = Math.max(0, Math.floor((end - start) / 1000));
|
||||
return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
|
||||
}
|
||||
74
src/features/activity/types.ts
Normal file
74
src/features/activity/types.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type {
|
||||
Cursor, Event, EventContext, EventId, ProjectId, ServerId, SessionId,
|
||||
Task, TaskId, Timestamp,
|
||||
} from "../../protocol/model";
|
||||
|
||||
export interface TaskOutputEntry {
|
||||
readonly id: string;
|
||||
readonly task_id: TaskId;
|
||||
readonly timestamp: Timestamp;
|
||||
readonly text: string;
|
||||
readonly redacted: boolean;
|
||||
}
|
||||
|
||||
export interface EventFiltersValue {
|
||||
readonly types: readonly string[];
|
||||
readonly project_id?: ProjectId;
|
||||
readonly session_id?: SessionId;
|
||||
readonly from?: Timestamp;
|
||||
readonly to?: Timestamp;
|
||||
}
|
||||
|
||||
export type StreamPhase = "idle" | "loading" | "streaming" | "degraded" | "reconnecting" | "recovery_failed" | "closed";
|
||||
|
||||
export interface ResumeGap {
|
||||
readonly requested_cursor?: Cursor;
|
||||
readonly resumed_cursor?: Cursor;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface ActivityState {
|
||||
readonly tasks: readonly Task[];
|
||||
readonly outputs: Readonly<Record<string, readonly TaskOutputEntry[]>>;
|
||||
readonly events: readonly Event[];
|
||||
readonly selected_task_id?: TaskId;
|
||||
readonly selected_event_id?: EventId;
|
||||
readonly stream_phase: StreamPhase;
|
||||
readonly last_cursor?: Cursor;
|
||||
readonly gap?: ResumeGap;
|
||||
readonly follow_events: boolean;
|
||||
readonly follow_output: boolean;
|
||||
}
|
||||
|
||||
export type ActivityAction =
|
||||
| { readonly type: "task_received"; readonly task: Task }
|
||||
| { readonly type: "task_output_received"; readonly output: TaskOutputEntry }
|
||||
| { readonly type: "event_received"; readonly event: Event }
|
||||
| { readonly type: "stream_phase_changed"; readonly phase: StreamPhase }
|
||||
| { readonly type: "resume_completed"; readonly cursor: Cursor; readonly gap?: ResumeGap }
|
||||
| { readonly type: "select_task"; readonly task_id?: TaskId }
|
||||
| { readonly type: "select_event"; readonly event_id?: EventId }
|
||||
| { readonly type: "set_follow_events"; readonly enabled: boolean }
|
||||
| { readonly type: "set_follow_output"; readonly enabled: boolean };
|
||||
|
||||
export interface ActivityScope {
|
||||
readonly server_id: ServerId;
|
||||
readonly project_id: ProjectId;
|
||||
readonly session_id?: SessionId;
|
||||
}
|
||||
|
||||
export interface ActivitySubscription {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ActivityAdapter {
|
||||
subscribe(scope: ActivityScope, cursor: Cursor | undefined, onEvent: (event: Event) => void, onPhase: (phase: StreamPhase, gap?: ResumeGap) => void): ActivitySubscription;
|
||||
cancelTask(task: Task): Promise<Task>;
|
||||
}
|
||||
|
||||
export function activityEvent(input: {
|
||||
event_id: EventId; cursor: Cursor; timestamp: Timestamp; sequence: number;
|
||||
type: Event["type"]; context: EventContext; payload: unknown;
|
||||
}): Event {
|
||||
return input;
|
||||
}
|
||||
23
src/features/connections/components.test.tsx
Normal file
23
src/features/connections/components.test.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConnectionCenter, ConnectionEditor } from "./components";
|
||||
import { MemoryMockConnectionAdapter } from "./mockAdapter";
|
||||
|
||||
describe("连接中心组件", () => {
|
||||
it("表单拒绝无效配置,秘密字段使用 password 类型", () => {
|
||||
render(<ConnectionEditor onSave={() => undefined} onCancel={() => undefined} />);
|
||||
expect(screen.getByLabelText("认证秘密")).toHaveAttribute("type", "password");
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("配置错误");
|
||||
});
|
||||
|
||||
it("通过 mock adapter 完成创建并明确未建立网络连接", async () => {
|
||||
render(<ConnectionCenter adapter={new MemoryMockConnectionAdapter()} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "+ 新建连接" }));
|
||||
fireEvent.change(screen.getByLabelText("名称"), { target: { value: "Atlas" } });
|
||||
fireEvent.change(screen.getByLabelText("地址"), { target: { value: "https://atlas.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "保存到内存" }));
|
||||
await waitFor(() => expect(screen.getByText("Atlas")).toBeInTheDocument());
|
||||
expect(screen.getByText(/尚未建立网络连接/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
109
src/features/connections/components.tsx
Normal file
109
src/features/connections/components.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { ConnectionAdapter } from "./mockAdapter";
|
||||
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
|
||||
|
||||
const field = (config: ConnectionConfig, onChange: (next: ConnectionConfig) => void) =>
|
||||
<K extends keyof ConnectionConfig>(key: K, value: ConnectionConfig[K]) => onChange({ ...config, [key]: value });
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return <fieldset className="connection-section"><legend>{title}</legend>{children}</fieldset>;
|
||||
}
|
||||
|
||||
export function TlsSettings({ config, onChange, developmentMode }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void; developmentMode: boolean }) {
|
||||
const set = (value: Partial<ConnectionConfig["tls"]>) => onChange({ ...config, tls: { ...config.tls, ...value } });
|
||||
return <Section title="TLS / mTLS">
|
||||
<label className="check"><input type="checkbox" checked={config.tls.enabled} onChange={(e) => set({ enabled: e.target.checked })} />启用 TLS</label>
|
||||
<label>自定义 CA<textarea value={config.tls.customCa} onChange={(e) => set({ customCa: e.target.value })} placeholder="PEM 内容(仅保存在内存)" /></label>
|
||||
<label>客户端证书<textarea value={config.tls.clientCertificate} onChange={(e) => set({ clientCertificate: e.target.value })} /></label>
|
||||
<label>客户端私钥<input type="password" autoComplete="off" value={config.tls.clientKey} onChange={(e) => set({ clientKey: e.target.value })} /></label>
|
||||
<label>证书指纹<input value={config.tls.fingerprint} onChange={(e) => set({ fingerprint: e.target.value })} placeholder="sha256:…" /></label>
|
||||
<label>SNI<input value={config.tls.sni} onChange={(e) => set({ sni: e.target.value })} /></label>
|
||||
{developmentMode && <label className="check danger"><input type="checkbox" checked={config.tls.skipVerification} onChange={(e) => set({ skipVerification: e.target.checked })} />跳过证书校验(仅开发模式,不安全)</label>}
|
||||
</Section>;
|
||||
}
|
||||
|
||||
export function AuthSettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
|
||||
const set = (value: Partial<ConnectionConfig["auth"]>) => onChange({ ...config, auth: { ...config.auth, ...value } });
|
||||
return <Section title="认证">
|
||||
<label>方式<select value={config.auth.method} onChange={(e) => set({ method: e.target.value as ConnectionConfig["auth"]["method"] })}><option value="token">Token</option><option value="password">用户名 / 密码</option><option value="mtls">仅 mTLS</option></select></label>
|
||||
{config.auth.method === "password" && <label>用户名<input value={config.auth.username} onChange={(e) => set({ username: e.target.value })} /></label>}
|
||||
{config.auth.method !== "mtls" && <label>秘密<input aria-label="认证秘密" type="password" autoComplete="new-password" value={config.auth.secret} onChange={(e) => set({ secret: e.target.value })} placeholder={maskedSecret(config.auth.secret)} /></label>}
|
||||
</Section>;
|
||||
}
|
||||
|
||||
export function ProxySettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
|
||||
const set = (value: Partial<ConnectionConfig["proxy"]>) => onChange({ ...config, proxy: { ...config.proxy, ...value } });
|
||||
return <Section title="代理">
|
||||
<label className="check"><input type="checkbox" checked={config.proxy.enabled} onChange={(e) => set({ enabled: e.target.checked })} />使用代理</label>
|
||||
{config.proxy.enabled && <><label>代理 URL<input value={config.proxy.url} onChange={(e) => set({ url: e.target.value })} placeholder="http://proxy.local:8080" /></label><label>代理用户名<input value={config.proxy.username} onChange={(e) => set({ username: e.target.value })} /></label><label>代理密码<input type="password" autoComplete="new-password" value={config.proxy.password} onChange={(e) => set({ password: e.target.value })} /></label></>}
|
||||
</Section>;
|
||||
}
|
||||
|
||||
export function ReconnectPolicy({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
|
||||
const set = (value: Partial<ConnectionConfig["reconnect"]>) => onChange({ ...config, reconnect: { ...config.reconnect, ...value } });
|
||||
return <Section title="重连策略">
|
||||
<label className="check"><input type="checkbox" checked={config.reconnect.enabled} onChange={(e) => set({ enabled: e.target.checked })} />指数退避重连</label>
|
||||
<label>初始退避(秒)<input type="number" value={config.reconnect.initialSeconds} onChange={(e) => set({ initialSeconds: Number(e.target.value) })} /></label>
|
||||
<label>最大退避(秒)<input type="number" value={config.reconnect.maxSeconds} onChange={(e) => set({ maxSeconds: Number(e.target.value) })} /></label>
|
||||
<label>倍率<input type="number" step="0.1" value={config.reconnect.multiplier} onChange={(e) => set({ multiplier: Number(e.target.value) })} /></label>
|
||||
<label>抖动(0–1)<input type="number" step="0.1" value={config.reconnect.jitter} onChange={(e) => set({ jitter: Number(e.target.value) })} /></label>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
export function ConnectionEditor({ initial, developmentMode = false, onSave, onCancel }: { initial?: ConnectionConfig; developmentMode?: boolean; onSave: (config: ConnectionConfig) => void; onCancel: () => void }) {
|
||||
const [config, setConfig] = useState(() => structuredClone(initial ?? blankConnection()));
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const errors = useMemo(() => validateConnection(config, developmentMode), [config, developmentMode]);
|
||||
const set = field(config, setConfig);
|
||||
return <form className="connection-editor" onSubmit={(e) => { e.preventDefault(); setSubmitted(true); if (!Object.keys(errors).length) onSave(config); }}>
|
||||
<div className="editor-grid"><Section title="基本设置">
|
||||
<label>名称<input value={config.name} onChange={(e) => set("name", e.target.value)} />{submitted && errors.name && <small className="field-error">{errors.name}</small>}</label>
|
||||
<label>传输<select value={config.transport} onChange={(e) => set("transport", e.target.value as ConnectionConfig["transport"])}><option value="grpc">gRPC</option><option value="websocket">WebSocket</option></select></label>
|
||||
<label>地址<input value={config.address} onChange={(e) => set("address", e.target.value)} placeholder={config.transport === "grpc" ? "https://teamserver.example" : "wss://teamserver.example/events"} />{submitted && errors.address && <small className="field-error">{errors.address}</small>}</label>
|
||||
<label>请求超时(秒)<input type="number" value={config.timeoutSeconds} onChange={(e) => set("timeoutSeconds", Number(e.target.value))} /></label>
|
||||
<label>心跳(秒)<input type="number" value={config.heartbeatSeconds} onChange={(e) => set("heartbeatSeconds", Number(e.target.value))} /></label>
|
||||
</Section><TlsSettings config={config} onChange={setConfig} developmentMode={developmentMode} /><AuthSettings config={config} onChange={setConfig} /><ProxySettings config={config} onChange={setConfig} /><ReconnectPolicy config={config} onChange={setConfig} /></div>
|
||||
{submitted && Object.keys(errors).length > 0 && <p role="alert" className="form-error">请修正 {Object.keys(errors).length} 项配置错误</p>}
|
||||
<footer><button type="button" className="secondary" onClick={onCancel}>取消</button><button className="primary" type="submit">保存到内存</button></footer>
|
||||
</form>;
|
||||
}
|
||||
|
||||
export function ConnectionList({ records, selectedId, onSelect, onCreate, onCopy, onExport, onDelete }: { records: ConnectionRecord[]; selectedId?: string; onSelect: (id: string) => void; onCreate: () => void; onCopy: (record: ConnectionRecord) => void; onExport: (record: ConnectionRecord) => void; onDelete: (record: ConnectionRecord) => void }) {
|
||||
return <section className="connection-list" aria-label="连接配置列表"><header><div><h2>连接中心</h2><p>{records.length} 个内存配置 · Mock adapter</p></div><button className="primary" onClick={onCreate}>+ 新建连接</button></header>
|
||||
<div className="state-legend" aria-label="连接状态机">{connectionStates.map((state) => <span key={state}>{state}</span>)}</div>
|
||||
{records.length === 0 ? <div className="empty-state">尚无连接配置。配置仅保存在当前进程内存。</div> : records.map((record) => <article key={record.config.id} className={selectedId === record.config.id ? "selected" : ""} onClick={() => onSelect(record.config.id)}>
|
||||
<div><strong>{record.config.name}</strong><code>{record.config.address}</code></div><span className={`connection-state ${record.state.toLowerCase()}`}>{record.state}</span>
|
||||
<div className="record-actions"><button onClick={(e) => { e.stopPropagation(); onCopy(record); }}>复制</button><button onClick={(e) => { e.stopPropagation(); onExport(record); }}>安全导出</button><button className="danger-button" onClick={(e) => { e.stopPropagation(); onDelete(record); }}>删除</button></div>
|
||||
</article>)}</section>;
|
||||
}
|
||||
|
||||
const categories: DiagnosticCategory[] = ["network", "tls", "authentication", "version", "permission", "rate_limit", "timeout", "message_format", "server"];
|
||||
export function ConnectionDiagnostics({ record, adapter }: { record: ConnectionRecord; adapter: ConnectionAdapter }) {
|
||||
const [results, setResults] = useState<DiagnosticResult[]>([]);
|
||||
return <section className="diagnostics"><h3>连接诊断</h3><p>仅检查 mock 配置结构,不向地址发送请求。</p><button className="secondary" onClick={async () => setResults(await Promise.all(categories.map((category) => adapter.diagnose(record.config.id, category))))}>运行安全诊断</button><div>{results.map((result) => <output key={result.category}><b>{result.category}</b><span>{result.summary}</span></output>)}</div></section>;
|
||||
}
|
||||
|
||||
export function DeleteConfirmation({ record, onConfirm, onCancel }: { record: ConnectionRecord; onConfirm: () => void; onCancel: () => void }) {
|
||||
return <div className="modal-backdrop" role="presentation"><section role="dialog" aria-modal="true" aria-labelledby="delete-title" className="connection-dialog"><h3 id="delete-title">删除“{record.config.name}”?</h3><p>仅移除当前进程中的 mock 配置,不会联系 Teamserver。</p><footer><button className="secondary" onClick={onCancel}>取消</button><button className="danger-button" onClick={onConfirm}>确认删除</button></footer></section></div>;
|
||||
}
|
||||
|
||||
export function ConnectionCenter({ adapter, developmentMode = false }: { adapter: ConnectionAdapter; developmentMode?: boolean }) {
|
||||
const [records, setRecords] = useState<ConnectionRecord[]>([]);
|
||||
const [editing, setEditing] = useState<ConnectionConfig>();
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [deleting, setDeleting] = useState<ConnectionRecord>();
|
||||
const [importText, setImportText] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
useEffect(() => { void adapter.list().then(setRecords); }, [adapter]);
|
||||
const refresh = () => adapter.list().then(setRecords);
|
||||
const selectedRecord = records.find((item) => item.config.id === selected);
|
||||
const importPreview = () => { try { setEditing(parseImportPreview(importText)); setNotice("导入预览已加载;秘密字段已清空,保存前请确认。"); } catch (error) { setNotice(error instanceof Error ? error.message : "导入失败"); } };
|
||||
const exportRecord = (record: ConnectionRecord) => { const safe = JSON.stringify(toSafeExport(record.config), null, 2); setNotice(`安全导出预览(不含 Token、密码和客户端私钥):\n${safe}`); };
|
||||
if (editing) return <ConnectionEditor initial={editing} developmentMode={developmentMode} onCancel={() => setEditing(undefined)} onSave={async (config) => { await adapter.save(config); await refresh(); setSelected(config.id); setEditing(undefined); setNotice("配置已保存到内存 mock adapter;尚未建立网络连接。"); }} />;
|
||||
return <div className="connection-center"><ConnectionList records={records} selectedId={selected} onSelect={setSelected} onCreate={() => setEditing(blankConnection())} onCopy={(record) => setEditing({ ...structuredClone(record.config), id: crypto.randomUUID(), name: `${record.config.name} 副本`, auth: { ...record.config.auth, secret: "" }, proxy: { ...record.config.proxy, password: "" }, tls: { ...record.config.tls, clientKey: "" } })} onExport={exportRecord} onDelete={setDeleting} />
|
||||
<section className="import-panel"><h3>导入预览</h3><textarea aria-label="导入 JSON" value={importText} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setImportText(e.target.value)} placeholder="粘贴由本客户端安全导出的 JSON" /><button className="secondary" onClick={importPreview}>验证并预览</button></section>
|
||||
{selectedRecord && <ConnectionDiagnostics record={selectedRecord} adapter={adapter} />}
|
||||
{notice && <pre className="connection-notice" aria-live="polite">{notice}</pre>}
|
||||
{deleting && <DeleteConfirmation record={deleting} onCancel={() => setDeleting(undefined)} onConfirm={async () => { await adapter.remove(deleting.config.id); await refresh(); setDeleting(undefined); if (selected === deleting.config.id) setSelected(undefined); }} />}
|
||||
</div>;
|
||||
}
|
||||
3
src/features/connections/index.ts
Normal file
3
src/features/connections/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./model";
|
||||
export * from "./mockAdapter";
|
||||
export * from "./components";
|
||||
29
src/features/connections/mockAdapter.ts
Normal file
29
src/features/connections/mockAdapter.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { ConnectionConfig, ConnectionRecord, DiagnosticCategory, DiagnosticResult } from "./model";
|
||||
|
||||
export interface ConnectionAdapter {
|
||||
readonly kind: "mock";
|
||||
list(): Promise<ConnectionRecord[]>;
|
||||
save(config: ConnectionConfig): Promise<ConnectionRecord>;
|
||||
remove(id: string): Promise<void>;
|
||||
diagnose(id: string, category: DiagnosticCategory): Promise<DiagnosticResult>;
|
||||
}
|
||||
|
||||
/** 仅用于安全的内存交互演示;不会发起网络请求,也不会持久化秘密。 */
|
||||
export class MemoryMockConnectionAdapter implements ConnectionAdapter {
|
||||
readonly kind = "mock" as const;
|
||||
private records: ConnectionRecord[];
|
||||
|
||||
constructor(seed: ConnectionRecord[] = []) { this.records = structuredClone(seed); }
|
||||
async list() { return structuredClone(this.records); }
|
||||
async save(config: ConnectionConfig) {
|
||||
const record = { config: structuredClone(config), state: "DISCONNECTED" as const };
|
||||
const index = this.records.findIndex((item) => item.config.id === config.id);
|
||||
if (index === -1) this.records.push(record); else this.records[index] = record;
|
||||
return structuredClone(record);
|
||||
}
|
||||
async remove(id: string) { this.records = this.records.filter((item) => item.config.id !== id); }
|
||||
async diagnose(id: string, category: DiagnosticCategory) {
|
||||
if (!this.records.some((item) => item.config.id === id)) throw new Error("连接不存在");
|
||||
return { category, ok: true, summary: `Mock 检查:${category} 配置结构有效(未连接真实服务器)` };
|
||||
}
|
||||
}
|
||||
41
src/features/connections/model.test.ts
Normal file
41
src/features/connections/model.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { blankConnection, connectionStates, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
|
||||
|
||||
function validConfig() {
|
||||
const config = blankConnection();
|
||||
config.name = "Atlas";
|
||||
config.address = "https://atlas.example";
|
||||
return config;
|
||||
}
|
||||
|
||||
describe("连接配置安全边界", () => {
|
||||
it("验证地址、URL 凭据及开发模式 TLS 约束", () => {
|
||||
const config = validConfig();
|
||||
config.address = "https://user:password@atlas.example";
|
||||
config.tls.skipVerification = true;
|
||||
expect(validateConnection(config)).toMatchObject({ address: "地址中不得包含凭据", skipVerification: "仅开发模式允许跳过证书校验" });
|
||||
expect(validateConnection({ ...config, address: "https://atlas.example" }, true)).toEqual({});
|
||||
});
|
||||
|
||||
it("安全导出排除全部秘密并在导入时生成新 ID", () => {
|
||||
const config = validConfig();
|
||||
config.auth.secret = "token-value";
|
||||
config.proxy.password = "proxy-password";
|
||||
config.tls.clientKey = "private-key";
|
||||
const json = JSON.stringify(toSafeExport(config));
|
||||
expect(json).not.toContain("token-value");
|
||||
expect(json).not.toContain("proxy-password");
|
||||
expect(json).not.toContain("private-key");
|
||||
const imported = parseImportPreview(json);
|
||||
expect(imported.id).not.toBe(config.id);
|
||||
expect(imported.auth.secret).toBe("");
|
||||
expect(imported.proxy.password).toBe("");
|
||||
expect(imported.tls.clientKey).toBe("");
|
||||
});
|
||||
|
||||
it("覆盖完整状态机且脱敏函数不回显原秘密", () => {
|
||||
expect(connectionStates).toHaveLength(11);
|
||||
expect(connectionStates).toContain("AUTH_EXPIRED");
|
||||
expect(maskedSecret("sensitive")).toBe("••••••••");
|
||||
});
|
||||
});
|
||||
99
src/features/connections/model.ts
Normal file
99
src/features/connections/model.ts
Normal file
@ -0,0 +1,99 @@
|
||||
export const connectionStates = [
|
||||
"DISCONNECTED", "CONNECTING", "TLS_HANDSHAKE", "AUTHENTICATING", "NEGOTIATING",
|
||||
"SYNCHRONIZING", "CONNECTED", "DEGRADED", "RECONNECTING", "AUTH_EXPIRED", "FAILED",
|
||||
] as const;
|
||||
|
||||
export type ConnectionState = (typeof connectionStates)[number];
|
||||
export type Transport = "grpc" | "websocket";
|
||||
export type AuthMethod = "token" | "password" | "mtls";
|
||||
|
||||
export interface ConnectionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
transport: Transport;
|
||||
tls: {
|
||||
enabled: boolean;
|
||||
customCa: string;
|
||||
clientCertificate: string;
|
||||
clientKey: string;
|
||||
fingerprint: string;
|
||||
sni: string;
|
||||
skipVerification: boolean;
|
||||
};
|
||||
auth: { method: AuthMethod; username: string; secret: string };
|
||||
proxy: { enabled: boolean; url: string; username: string; password: string };
|
||||
timeoutSeconds: number;
|
||||
heartbeatSeconds: number;
|
||||
reconnect: { enabled: boolean; initialSeconds: number; maxSeconds: number; multiplier: number; jitter: number };
|
||||
}
|
||||
|
||||
export interface ConnectionRecord { config: ConnectionConfig; state: ConnectionState; }
|
||||
|
||||
export type DiagnosticCategory = "network" | "tls" | "authentication" | "version" | "permission" | "rate_limit" | "timeout" | "message_format" | "server";
|
||||
export interface DiagnosticResult { category: DiagnosticCategory; ok: boolean; summary: string; }
|
||||
|
||||
export const blankConnection = (): ConnectionConfig => ({
|
||||
id: crypto.randomUUID(), name: "", address: "", transport: "grpc",
|
||||
tls: { enabled: true, customCa: "", clientCertificate: "", clientKey: "", fingerprint: "", sni: "", skipVerification: false },
|
||||
auth: { method: "token", username: "", secret: "" },
|
||||
proxy: { enabled: false, url: "", username: "", password: "" },
|
||||
timeoutSeconds: 15, heartbeatSeconds: 30,
|
||||
reconnect: { enabled: true, initialSeconds: 1, maxSeconds: 30, multiplier: 2, jitter: 0.2 },
|
||||
});
|
||||
|
||||
export function validateConnection(config: ConnectionConfig, developmentMode = false): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!config.name.trim()) errors.name = "请输入连接名称";
|
||||
try {
|
||||
const url = new URL(config.address);
|
||||
const allowed = config.transport === "grpc" ? ["https:", "http:"] : ["wss:", "ws:"];
|
||||
if (!allowed.includes(url.protocol)) errors.address = `传输 ${config.transport} 不支持 ${url.protocol}`;
|
||||
if (url.username || url.password) errors.address = "地址中不得包含凭据";
|
||||
} catch { errors.address = "请输入包含协议的有效地址"; }
|
||||
if (config.tls.skipVerification && !developmentMode) errors.skipVerification = "仅开发模式允许跳过证书校验";
|
||||
if (config.tls.fingerprint && !/^sha256:[a-f0-9]{64}$/i.test(config.tls.fingerprint)) errors.fingerprint = "指纹格式应为 sha256: 后接 64 位十六进制字符";
|
||||
if (config.timeoutSeconds < 1 || config.timeoutSeconds > 300) errors.timeoutSeconds = "超时范围为 1–300 秒";
|
||||
if (config.heartbeatSeconds < 5 || config.heartbeatSeconds > 3600) errors.heartbeatSeconds = "心跳范围为 5–3600 秒";
|
||||
if (config.reconnect.initialSeconds > config.reconnect.maxSeconds) errors.reconnect = "初始退避不能大于最大退避";
|
||||
if (config.reconnect.jitter < 0 || config.reconnect.jitter > 1) errors.jitter = "抖动范围为 0–1";
|
||||
if (config.proxy.enabled) {
|
||||
try {
|
||||
const proxy = new URL(config.proxy.url);
|
||||
if (proxy.username || proxy.password) errors.proxy = "代理 URL 中不得包含凭据";
|
||||
} catch { errors.proxy = "请输入有效代理 URL"; }
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export type SafeConnectionExport = Omit<ConnectionConfig, "auth" | "proxy" | "tls"> & {
|
||||
auth: Omit<ConnectionConfig["auth"], "secret">;
|
||||
proxy: Omit<ConnectionConfig["proxy"], "password">;
|
||||
tls: Omit<ConnectionConfig["tls"], "clientKey">;
|
||||
exportVersion: 1;
|
||||
};
|
||||
|
||||
export function toSafeExport(config: ConnectionConfig): SafeConnectionExport {
|
||||
const { secret: _secret, ...auth } = config.auth;
|
||||
const { password: _password, ...proxy } = config.proxy;
|
||||
const { clientKey: _clientKey, ...tls } = config.tls;
|
||||
return { ...config, auth, proxy, tls, exportVersion: 1 };
|
||||
}
|
||||
|
||||
export function parseImportPreview(raw: string): ConnectionConfig {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("导入内容必须是 JSON 对象");
|
||||
const input = parsed as Partial<SafeConnectionExport>;
|
||||
if (input.exportVersion !== 1) throw new Error("不支持的导出版本");
|
||||
const base = blankConnection();
|
||||
const imported: ConnectionConfig = {
|
||||
...base, ...input, id: crypto.randomUUID(),
|
||||
tls: { ...base.tls, ...input.tls, clientKey: "" },
|
||||
auth: { ...base.auth, ...input.auth, secret: "" },
|
||||
proxy: { ...base.proxy, ...input.proxy, password: "" },
|
||||
};
|
||||
if (Object.keys(validateConnection(imported)).length) throw new Error("导入配置未通过安全校验");
|
||||
return imported;
|
||||
}
|
||||
|
||||
export function maskedSecret(secret: string): string { return secret ? "••••••••" : "未设置"; }
|
||||
103
src/features/records/index.tsx
Normal file
103
src/features/records/index.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
|
||||
export type LoadState = "ready" | "loading" | "empty" | "error";
|
||||
export interface ArtifactRecord { id: string; name: string; size: number; mimeType: string; sha256: string; createdAt: string; }
|
||||
export interface ArtifactContent { blob: Blob; }
|
||||
export interface ArtifactExportTarget { path: string; overwrite: boolean; }
|
||||
export interface ArtifactSaveAdapter { save(artifact: ArtifactRecord, content: Blob, target: ArtifactExportTarget): Promise<void>; }
|
||||
export interface ArtifactContentAdapter { load(id: string): Promise<ArtifactContent>; }
|
||||
|
||||
export const DEFAULT_PREVIEW_LIMIT = 5 * 1024 * 1024;
|
||||
const TEXT_MIMES = new Set(["application/json", "application/xml", "application/yaml"]);
|
||||
export function previewKind(mime: string): "text" | "image" | "unsupported" {
|
||||
if (mime.startsWith("text/") || TEXT_MIMES.has(mime)) return "text";
|
||||
if (mime.startsWith("image/") && mime !== "image/svg+xml") return "image";
|
||||
return "unsupported";
|
||||
}
|
||||
export function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function StatePanel({ state, error }: { state: LoadState; error?: string }) {
|
||||
if (state === "loading") return <p role="status">正在加载…</p>;
|
||||
if (state === "empty") return <p role="status">暂无记录</p>;
|
||||
if (state === "error") return <p role="alert">加载失败:{error ?? "未知错误"}</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ArtifactList({ artifacts, state = "ready", error, selectedId, onSelect }: { artifacts: ArtifactRecord[]; state?: LoadState; error?: string; selectedId?: string; onSelect: (artifact: ArtifactRecord) => void }) {
|
||||
if (state !== "ready") return <StatePanel state={state} error={error} />;
|
||||
if (!artifacts.length) return <StatePanel state="empty" />;
|
||||
return <section aria-label="Artifact 列表" className="records-table">{artifacts.map((item) => <button key={item.id} aria-pressed={selectedId === item.id} onClick={() => onSelect(item)}><span>{item.name}</span><small>{item.mimeType} · {formatBytes(item.size)}</small></button>)}</section>;
|
||||
}
|
||||
|
||||
export function ArtifactMetadata({ artifact }: { artifact: ArtifactRecord }) {
|
||||
return <dl aria-label="Artifact 元数据"><dt>文件名</dt><dd>{artifact.name}</dd><dt>类型</dt><dd>{artifact.mimeType}</dd><dt>大小</dt><dd>{formatBytes(artifact.size)}</dd><dt>SHA-256</dt><dd className="mono">{artifact.sha256}</dd><dt>创建时间</dt><dd>{artifact.createdAt}</dd></dl>;
|
||||
}
|
||||
|
||||
export function ArtifactPreview({ artifact, content, state = "ready", error, maxBytes = DEFAULT_PREVIEW_LIMIT }: { artifact: ArtifactRecord; content?: ArtifactContent; state?: LoadState; error?: string; maxBytes?: number }) {
|
||||
const kind = previewKind(artifact.mimeType);
|
||||
const [imageUrl, setImageUrl] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!content || kind !== "image") { setImageUrl(undefined); return; }
|
||||
const url = URL.createObjectURL(content.blob);
|
||||
setImageUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [content, kind]);
|
||||
if (state !== "ready") return <StatePanel state={state} error={error} />;
|
||||
if (artifact.size > maxBytes) return <p role="status">文件过大,无法预览(上限 {formatBytes(maxBytes)})。可显式导出后使用受信任工具查看。</p>;
|
||||
if (kind === "unsupported") return <p role="status">不支持安全内嵌预览此格式。内容不会执行,可显式导出。</p>;
|
||||
if (!content) return <p role="status">尚未加载预览内容</p>;
|
||||
if (kind === "image") {
|
||||
return imageUrl ? <img src={imageUrl} alt={`${artifact.name} 预览`} /> : <p role="status">正在准备图片…</p>;
|
||||
}
|
||||
return <TextBlob blob={content.blob} />;
|
||||
}
|
||||
|
||||
function TextBlob({ blob }: { blob: Blob }) {
|
||||
const [text, setText] = useState<string>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => { let active = true; setText(undefined); setFailed(false); blob.text().then((value) => active && setText(value), () => active && setFailed(true)); return () => { active = false; }; }, [blob]);
|
||||
if (failed) return <p role="alert">无法读取文本预览</p>;
|
||||
return text === undefined ? <p role="status">正在解码文本…</p> : <pre>{text}</pre>;
|
||||
}
|
||||
|
||||
export function ExportArtifactDialog({ artifact, content, adapter, onClose }: { artifact: ArtifactRecord; content: Blob; adapter: ArtifactSaveAdapter; onClose: () => void }) {
|
||||
const [path, setPath] = useState(artifact.name);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "error">("idle");
|
||||
const save = async () => { setStatus("saving"); try { await adapter.save(artifact, content, { path, overwrite }); onClose(); } catch { setStatus("error"); } };
|
||||
return <section role="dialog" aria-modal="true" aria-labelledby="export-title"><h2 id="export-title">导出 Artifact</h2><p>仅此确认操作会将内存内容写入磁盘。</p><ArtifactMetadata artifact={artifact} /><label>目标路径<input aria-label="目标路径" value={path} onChange={(e) => setPath(e.currentTarget.value)} /></label><label><input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.currentTarget.checked)} />允许覆盖已有文件(存在数据丢失风险)</label>{status === "error" && <p role="alert">导出失败,未确认文件已写入。</p>}<button onClick={onClose}>取消</button><button disabled={!path.trim() || status === "saving"} onClick={save}>确认导出</button></section>;
|
||||
}
|
||||
|
||||
const SECRET_PATTERN = /(token|cookie|password|passwd|secret|api[_-]?key|private[_-]?key|authorization)/i;
|
||||
export interface RedactedValue { value: unknown; redacted: boolean; }
|
||||
export function redactSecrets(value: unknown, key = ""): unknown {
|
||||
if (SECRET_PATTERN.test(key)) return "[已脱敏]";
|
||||
if (Array.isArray(value)) return value.map((item) => redactSecrets(item));
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([childKey, child]) => [childKey, redactSecrets(child, childKey)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ParameterSummary({ parameters, allowReveal = false, onReveal }: { parameters: Record<string, unknown>; allowReveal?: boolean; onReveal?: () => void }) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const shown = revealed ? parameters : redactSecrets(parameters);
|
||||
return <div><pre aria-label="参数摘要">{JSON.stringify(shown, null, 2)}</pre>{allowReveal && !revealed && <button onClick={() => { setRevealed(true); onReveal?.(); }}>显示原始敏感值(将产生审计提示)</button>}</div>;
|
||||
}
|
||||
|
||||
export interface AuditRecord { id: string; actor: string; target: string; operation: string; parameters: Record<string, unknown>; result: string; requestId: string; timestamp: string; }
|
||||
export type AuditQueryIntent = { type: "filter"; field: "actor" | "target" | "operation" | "result"; value: string } | { type: "page"; cursor?: string; direction: "previous" | "next" };
|
||||
export function RequestIdLink({ requestId, onOpen }: { requestId: string; onOpen?: (requestId: string) => void }) { return onOpen ? <button className="mono" onClick={() => onOpen(requestId)}>{requestId}</button> : <span className="mono">{requestId}</span>; }
|
||||
export function AuditTable({ records, state = "ready", error, nextCursor, previousCursor, onQueryIntent, onSelect }: { records: AuditRecord[]; state?: LoadState; error?: string; nextCursor?: string; previousCursor?: string; onQueryIntent: (intent: AuditQueryIntent) => void; onSelect: (record: AuditRecord) => void }) {
|
||||
if (state !== "ready") return <StatePanel state={state} error={error} />;
|
||||
return <section><label>操作筛选<input aria-label="操作筛选" onChange={(e) => onQueryIntent({ type: "filter", field: "operation", value: e.currentTarget.value })} /></label>{records.length ? <table><thead><tr><th>时间</th><th>操作者</th><th>目标</th><th>操作</th><th>结果</th><th>Request ID</th></tr></thead><tbody>{records.map((record) => <tr key={record.id} onClick={() => onSelect(record)}><td>{record.timestamp}</td><td>{record.actor}</td><td>{record.target}</td><td>{record.operation}</td><td>{record.result}</td><td><RequestIdLink requestId={record.requestId} /></td></tr>)}</tbody></table> : <StatePanel state="empty" />}<footer><button disabled={!previousCursor} onClick={() => onQueryIntent({ type: "page", direction: "previous", cursor: previousCursor })}>上一页</button><button disabled={!nextCursor} onClick={() => onQueryIntent({ type: "page", direction: "next", cursor: nextCursor })}>下一页</button></footer></section>;
|
||||
}
|
||||
export function AuditDetail({ record, allowReveal, onReveal }: { record: AuditRecord; allowReveal?: boolean; onReveal?: () => void }) { return <article><h2>审计详情</h2><dl><dt>操作者</dt><dd>{record.actor}</dd><dt>目标</dt><dd>{record.target}</dd><dt>操作</dt><dd>{record.operation}</dd><dt>结果</dt><dd>{record.result}</dd><dt>Request ID</dt><dd><RequestIdLink requestId={record.requestId} /></dd><dt>时间</dt><dd>{record.timestamp}</dd></dl><ParameterSummary parameters={record.parameters} allowReveal={allowReveal} onReveal={onReveal} /></article>; }
|
||||
|
||||
export function MemoryOnlyArtifactController({ adapter, artifact, children }: { adapter: ArtifactContentAdapter; artifact: ArtifactRecord; children: (state: { content?: ArtifactContent; state: LoadState; load: () => Promise<void> }) => ReactNode }) {
|
||||
const [content, setContent] = useState<ArtifactContent>(); const [state, setState] = useState<LoadState>("empty");
|
||||
const load = async () => { setState("loading"); try { setContent(await adapter.load(artifact.id)); setState("ready"); } catch { setState("error"); } };
|
||||
return <>{children({ content, state, load })}</>;
|
||||
}
|
||||
44
src/features/records/records.test.tsx
Normal file
44
src/features/records/records.test.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ArtifactPreview, AuditTable, ExportArtifactDialog, ParameterSummary, previewKind, redactSecrets, type ArtifactRecord, type AuditRecord } from ".";
|
||||
|
||||
const artifact: ArtifactRecord = { id: "a1", name: "report.txt", size: 12, mimeType: "text/plain", sha256: "abc123", createdAt: "2026-08-03T10:00:00Z" };
|
||||
const audit: AuditRecord = { id: "e1", actor: "analyst", target: "session/s1", operation: "artifact.export", parameters: { path: "/safe", token: "secret", nested: { password: "pw" } }, result: "success", requestId: "req-1", timestamp: "2026-08-03T10:00:00Z" };
|
||||
|
||||
describe("Artifact records", () => {
|
||||
it("限制危险 MIME 和过大内容的内嵌预览", () => {
|
||||
expect(previewKind("image/svg+xml")).toBe("unsupported");
|
||||
const { rerender } = render(<ArtifactPreview artifact={{ ...artifact, mimeType: "application/x-executable" }} />);
|
||||
expect(screen.getByText(/不支持安全内嵌/)).toBeInTheDocument();
|
||||
rerender(<ArtifactPreview artifact={{ ...artifact, size: 11 }} maxBytes={10} />);
|
||||
expect(screen.getByText(/文件过大/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("只有用户确认后才调用保存 adapter,并携带覆盖意图", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined); const close = vi.fn();
|
||||
render(<ExportArtifactDialog artifact={artifact} content={new Blob(["hello"])} adapter={{ save }} onClose={close} />);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("目标路径"), { target: { value: "/exports/report.txt" } });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认导出" }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith(artifact, expect.any(Blob), { path: "/exports/report.txt", overwrite: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit records", () => {
|
||||
it("递归脱敏常见秘密字段,原始值需要显式动作", () => {
|
||||
expect(redactSecrets(audit.parameters)).toEqual({ path: "/safe", token: "[已脱敏]", nested: { password: "[已脱敏]" } });
|
||||
const reveal = vi.fn(); render(<ParameterSummary parameters={audit.parameters} allowReveal onReveal={reveal} />);
|
||||
expect(screen.queryByText(/"secret"/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /显示原始敏感值/ }));
|
||||
expect(screen.getByLabelText("参数摘要")).toHaveTextContent("secret"); expect(reveal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("筛选和游标分页只发出服务端查询意图", () => {
|
||||
const intent = vi.fn(); render(<AuditTable records={[audit]} nextCursor="next-2" onQueryIntent={intent} onSelect={() => undefined} />);
|
||||
fireEvent.change(screen.getByLabelText("操作筛选"), { target: { value: "export" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "下一页" }));
|
||||
expect(intent).toHaveBeenNthCalledWith(1, { type: "filter", field: "operation", value: "export" });
|
||||
expect(intent).toHaveBeenNthCalledWith(2, { type: "page", direction: "next", cursor: "next-2" });
|
||||
});
|
||||
});
|
||||
@ -4,5 +4,5 @@ import type { TeamserverWorkspace } from "../workspace";
|
||||
const navigation = ["Overview", "Sessions", "Tasks", "Events", "Artifacts", "Audit"];
|
||||
export interface ProjectSidebarProps { server: TeamserverWorkspace; activePage: string; onNavigate?: (page: string) => void; }
|
||||
export function ProjectSidebar({ server, activePage, onNavigate }: ProjectSidebarProps) {
|
||||
return <aside className="project-sidebar"><header><p className="eyebrow">TEAMSERVER</p><h1>{server.name}</h1><StatusIndicator status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} /></header><Input className="sidebar-search" aria-label="搜索" placeholder="搜索" leading={<Icon name="search" />} /><nav aria-label="项目导航"><button>收件箱 <strong>{server.unread}</strong></button><p className="nav-heading">项目 · {server.activeProject}</p>{navigation.map((item) => <button className={item === activePage ? "selected" : ""} key={item} onClick={() => onNavigate?.(item)}>{item}</button>)}</nav><p className="memory-note">仅内存状态 · 退出后清除</p></aside>;
|
||||
return <aside className="project-sidebar"><header><p className="eyebrow">TEAMSERVER</p><h1>{server.name}</h1><StatusIndicator status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} /></header><Input className="sidebar-search" aria-label="搜索" placeholder="搜索" leading={<Icon name="search" />} /><nav aria-label="项目导航"><button>收件箱 <strong>{server.unread}</strong></button><button className={activePage === "连接中心" ? "selected" : ""} onClick={() => onNavigate?.("连接中心")}>连接中心</button><p className="nav-heading">项目 · {server.activeProject}</p>{navigation.map((item) => <button className={item === activePage ? "selected" : ""} key={item} onClick={() => onNavigate?.(item)}>{item}</button>)}</nav><p className="memory-note">仅内存状态 · 退出后清除</p></aside>;
|
||||
}
|
||||
|
||||
@ -6,5 +6,5 @@ import { ProjectSidebar } from "./ProjectSidebar";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { TeamserverRail } from "./TeamserverRail";
|
||||
|
||||
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; }
|
||||
export function WorkspaceLayout({ servers, server, activeTab, children, onSwitchServer, onActivateTab }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activeTab.title} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; }
|
||||
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; }
|
||||
export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activePage} onNavigate={onNavigate} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; }
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 0; min-height: 100vh; overflow: auto; background: var(--app); }
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible { outline: none; box-shadow: var(--focus-ring); position: relative; z-index: 1; }
|
||||
.shell { display: grid; grid-template-columns: 52px 260px minmax(600px, 1fr); min-width: 912px; min-height: 100vh; background: var(--app); }
|
||||
@ -98,3 +98,40 @@ input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
||||
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 220px minmax(600px,1fr); min-width:872px; } .content { padding-inline: 16px; } }
|
||||
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto!important;transition-duration:0.01ms!important;animation-duration:0.01ms!important;animation-iteration-count:1!important; } }
|
||||
@media (forced-colors: active) { button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:2px solid Highlight;outline-offset:2px;box-shadow:none; } }
|
||||
|
||||
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
|
||||
.connection-list { grid-row: span 3; }
|
||||
.connection-list > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.connection-list h2, .connection-list p, .diagnostics h3, .diagnostics p, .import-panel h3 { margin: 0; }
|
||||
.connection-list p, .diagnostics p { color: var(--muted); margin-top: 4px; }
|
||||
.state-legend { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.state-legend span { padding: 2px 5px; border: 1px solid var(--border); border-radius: 3px; color: #7f8998; font: 8px ui-monospace, monospace; }
|
||||
.connection-list article { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 13px; border: 1px solid var(--border); background: #15191f; margin-bottom: 7px; border-radius: 5px; }
|
||||
.connection-list article.selected { border-color: #4777ac; background: #1c2631; }
|
||||
.connection-list article strong, .connection-list article code { display: block; }
|
||||
.connection-list article code { color: var(--muted); margin-top: 4px; }
|
||||
.connection-state { align-self: start; padding: 3px 6px; border: 1px solid #48515e; border-radius: 3px; font: 9px ui-monospace, monospace; }
|
||||
.connection-state.connected { color: #82d5aa; border-color: #28523e; }
|
||||
.record-actions { grid-column: 1 / -1; display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.record-actions button, .danger-button { border: 1px solid var(--border); border-radius: 4px; padding: 5px 8px; background: #1b2027; }
|
||||
.danger-button { color: #f18b92; border-color: #6a353a; background: #301d21; }
|
||||
.empty-state, .import-panel, .diagnostics, .connection-notice { padding: 16px; border: 1px solid var(--border); background: #15191f; border-radius: 5px; color: var(--muted); }
|
||||
.import-panel textarea { width: 100%; min-height: 130px; margin: 10px 0; }
|
||||
.diagnostics > button { margin: 10px 0; }
|
||||
.diagnostics output { display: grid; grid-template-columns: 110px 1fr; padding: 6px 0; border-top: 1px solid var(--border); font-size: 10px; }
|
||||
.diagnostics output b { color: #87b5e8; font-family: ui-monospace, monospace; }
|
||||
.connection-notice { white-space: pre-wrap; max-height: 260px; overflow: auto; font-size: 10px; }
|
||||
.connection-editor { max-width: 1100px; }
|
||||
.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(300px, 1fr)); gap: 12px; align-items: start; }
|
||||
.connection-section { border: 1px solid var(--border); background: #15191f; border-radius: 5px; padding: 14px; display: grid; gap: 10px; }
|
||||
.connection-section legend { padding: 0 6px; color: #cbd3de; font-weight: 600; }
|
||||
.connection-section label { display: grid; gap: 5px; color: #9da6b4; font-size: 11px; }
|
||||
.connection-section input, .connection-section select, .connection-section textarea, .import-panel textarea { border: 1px solid #353d48; border-radius: 4px; background: #101318; padding: 7px 8px; outline: 0; }
|
||||
.connection-section textarea { min-height: 65px; resize: vertical; }
|
||||
.connection-section .check { display: flex; align-items: center; gap: 7px; }
|
||||
.connection-section .danger { color: #e8b36e; padding: 8px; border: 1px solid #65512f; background: #2b251a; }
|
||||
.field-error, .form-error { color: #f1888f; }
|
||||
.connection-editor > footer, .connection-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; background: #080a0dcc; }
|
||||
.connection-dialog { width: 410px; padding: 20px; border: 1px solid #454e5a; border-radius: 6px; background: #171b21; box-shadow: 0 15px 50px #000a; }
|
||||
@media (max-width: 1150px) { .connection-center, .editor-grid { grid-template-columns: 1fr; } .connection-list { grid-row: auto; } }
|
||||
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Loading…
Reference in New Issue
Block a user