Merge pull request '完善 Session 详情与能力操作组件' (#10) from agent/docker/f241e363 into main
Reviewed-on: #10
This commit is contained in:
commit
a0c15fa127
6
src/features/sessions/CapabilityPanel.tsx
Normal file
6
src/features/sessions/CapabilityPanel.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import type { Capability, CapabilityId } from "../../protocol/model";
|
||||||
|
|
||||||
|
export function CapabilityPanel({ declared, catalog, onSelect }: { declared: readonly CapabilityId[]; catalog: readonly Capability[]; onSelect?: (capability: Capability) => void }) {
|
||||||
|
const visible = catalog.filter((capability) => declared.includes(capability.capability_id));
|
||||||
|
return <section aria-labelledby="capabilities"><h3 id="capabilities">Capability</h3>{visible.length === 0 ? <p className="empty-state">服务端未声明可用操作</p> : <ul className="card-list">{visible.map((capability) => <li key={capability.capability_id}><div><strong>{capability.name}</strong><small>{capability.description ?? capability.capability_id}</small></div>{onSelect && <button onClick={() => onSelect(capability)}>打开操作</button>}</li>)}</ul>}</section>;
|
||||||
|
}
|
||||||
11
src/features/sessions/GroupAssignment.tsx
Normal file
11
src/features/sessions/GroupAssignment.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { GroupId, ProjectId, SessionGroup } from "../../protocol/model";
|
||||||
|
import type { GroupCommandIntent, SessionSelection } from "./types";
|
||||||
|
|
||||||
|
function flatten(groups: readonly SessionGroup[]): SessionGroup[] { return groups.flatMap((group) => [group, ...flatten(group.children ?? [])]); }
|
||||||
|
|
||||||
|
export function GroupAssignment({ projectId, groups, selection, onIntent }: { projectId: ProjectId; groups: readonly SessionGroup[]; selection: SessionSelection; onIntent: (intent: GroupCommandIntent) => void }) {
|
||||||
|
const [groupId, setGroupId] = useState<GroupId | "">("");
|
||||||
|
const count = selection.kind === "explicit" ? selection.sessionIds.length : selection.estimatedCount;
|
||||||
|
return <section><h3>移动 Session</h3><p>目标:{count === undefined ? "服务端查询范围(数量待服务端确认)" : `${count} 个 Session`}</p><label>目标分组<select aria-label="目标分组" value={groupId} onChange={(event) => setGroupId(event.currentTarget.value as GroupId | "")}><option value="">未分组</option>{flatten(groups).map((group) => <option value={group.group_id} key={group.group_id}>{group.name} ({group.online_count}/{group.total_count})</option>)}</select></label><button onClick={() => onIntent({ type: "assign-sessions", projectId, selection, groupId: groupId || undefined })}>生成移动命令</button></section>;
|
||||||
|
}
|
||||||
15
src/features/sessions/GroupTree.tsx
Normal file
15
src/features/sessions/GroupTree.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { GroupId, SessionGroup } from "../../protocol/model";
|
||||||
|
|
||||||
|
export function GroupTree({ groups, selectedGroupId, onSelect }: { groups: readonly SessionGroup[]; selectedGroupId?: GroupId; onSelect: (groupId?: GroupId) => void }) {
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<GroupId>>(new Set());
|
||||||
|
const render = (group: SessionGroup, depth: number) => {
|
||||||
|
const hasChildren = Boolean(group.children?.length);
|
||||||
|
const isCollapsed = collapsed.has(group.group_id);
|
||||||
|
return <li key={group.group_id}><div className={selectedGroupId === group.group_id ? "selected" : ""} style={{ paddingLeft: depth * 16 }}>
|
||||||
|
<button aria-label={`${isCollapsed ? "展开" : "折叠"} ${group.name}`} disabled={!hasChildren} onClick={() => setCollapsed((current) => { const next = new Set(current); next.has(group.group_id) ? next.delete(group.group_id) : next.add(group.group_id); return next; })}>{hasChildren ? isCollapsed ? "▸" : "▾" : "·"}</button>
|
||||||
|
<button onClick={() => onSelect(group.group_id)}>{group.name}</button><span>{group.online_count} / {group.total_count}</span>
|
||||||
|
</div>{hasChildren && !isCollapsed && <ul>{group.children?.map((child) => render(child, depth + 1))}</ul>}</li>;
|
||||||
|
};
|
||||||
|
return <nav className="group-tree" aria-label="Session 分组"><button className={!selectedGroupId ? "selected" : ""} onClick={() => onSelect(undefined)}>未分组</button><ul>{groups.map((group) => render(group, 0))}</ul></nav>;
|
||||||
|
}
|
||||||
9
src/features/sessions/OperationConfirmation.tsx
Normal file
9
src/features/sessions/OperationConfirmation.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { OperationIntent } from "./types";
|
||||||
|
|
||||||
|
function redact(key: string, value: unknown): unknown { return /password|secret|token|cookie|credential/i.test(key) ? "••••••" : value; }
|
||||||
|
|
||||||
|
export function OperationConfirmation({ intent, pending, onConfirm, onCancel }: { intent: OperationIntent; pending?: boolean; onConfirm: (intent: OperationIntent) => void; onCancel: () => void }) {
|
||||||
|
const { context, selection } = intent;
|
||||||
|
const target = selection.kind === "explicit" ? `${selection.sessionIds.length} 个明确 Session` : selection.estimatedCount === undefined ? "服务端查询范围(数量待确认)" : `${selection.estimatedCount} 个查询结果`;
|
||||||
|
return <section className="operation-confirmation" aria-labelledby="confirm-operation"><h3 id="confirm-operation">确认服务端操作</h3><p className="context-lock">◆ LOCKED CONTEXT</p><dl className="detail-grid"><div><dt>Teamserver</dt><dd>{context.serverName} ({context.serverId})</dd></div><div><dt>Project</dt><dd>{context.projectName} ({context.projectId})</dd></div><div><dt>Operation</dt><dd>{intent.capabilityId}</dd></div><div><dt>目标</dt><dd>{target}</dd></div></dl><h4>参数摘要</h4><pre>{JSON.stringify(Object.fromEntries(Object.entries(intent.parameters).map(([key, value]) => [key, redact(key, value)])), null, 2)}</pre><div><button disabled={pending} onClick={onCancel}>取消</button><button className="primary" disabled={pending} onClick={() => onConfirm(intent)}>{pending ? "提交中…" : "确认提交"}</button></div></section>;
|
||||||
|
}
|
||||||
28
src/features/sessions/OperationForm.tsx
Normal file
28
src/features/sessions/OperationForm.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
|
import type { Capability } from "../../protocol/model";
|
||||||
|
|
||||||
|
interface SchemaProperty { type?: unknown; title?: unknown; description?: unknown; enum?: unknown; default?: unknown; minimum?: unknown; maximum?: unknown; maxLength?: unknown; }
|
||||||
|
|
||||||
|
function properties(schema: Capability["input_schema"]): Record<string, SchemaProperty> {
|
||||||
|
const value = schema.properties;
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, SchemaProperty> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationForm({ capability, disabled, onSubmit }: { capability: Capability; disabled?: boolean; onSubmit: (parameters: Readonly<Record<string, unknown>>) => void }) {
|
||||||
|
const fields = useMemo(() => properties(capability.input_schema), [capability]);
|
||||||
|
const required = new Set(Array.isArray(capability.input_schema.required) ? capability.input_schema.required.filter((value): value is string => typeof value === "string") : []);
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>(() => Object.fromEntries(Object.entries(fields).filter(([, field]) => field.default !== undefined).map(([name, field]) => [name, field.default])));
|
||||||
|
const unsupported = Object.entries(fields).filter(([, field]) => !Array.isArray(field.enum) && ![undefined, "string", "boolean", "number", "integer"].includes(field.type as string | undefined)).map(([name]) => name);
|
||||||
|
const submit = (event: FormEvent) => { event.preventDefault(); if (unsupported.length === 0) onSubmit(values); };
|
||||||
|
const controls = Object.entries(fields).map(([name, field]) => {
|
||||||
|
const label = typeof field.title === "string" ? field.title : name;
|
||||||
|
const common = { id: `operation-${name}`, disabled, required: required.has(name) };
|
||||||
|
const enumValues = Array.isArray(field.enum) && field.enum.every((item) => ["string", "number"].includes(typeof item)) ? field.enum as readonly (string | number)[] : undefined;
|
||||||
|
if (enumValues) return <select {...common} value={String(values[name] ?? "")} onChange={(e) => setValues({ ...values, [name]: enumValues.find((item) => String(item) === e.currentTarget.value) })}><option value="">请选择</option>{enumValues.map((item) => <option key={String(item)} value={String(item)}>{String(item)}</option>)}</select>;
|
||||||
|
if (field.type === "boolean") return <input {...common} type="checkbox" checked={Boolean(values[name])} onChange={(e) => setValues({ ...values, [name]: e.currentTarget.checked })} />;
|
||||||
|
if (field.type === "number" || field.type === "integer") return <input {...common} type="number" min={typeof field.minimum === "number" ? field.minimum : undefined} max={typeof field.maximum === "number" ? field.maximum : undefined} value={String(values[name] ?? "")} onChange={(e) => setValues({ ...values, [name]: e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value) })} />;
|
||||||
|
if (field.type === "string" || field.type === undefined) return <input {...common} type="text" maxLength={typeof field.maxLength === "number" ? field.maxLength : undefined} value={String(values[name] ?? "")} onChange={(e) => setValues({ ...values, [name]: e.currentTarget.value })} />;
|
||||||
|
return <p role="alert">字段 {name} 使用不支持的 Schema 类型</p>;
|
||||||
|
});
|
||||||
|
return <form className="operation-form" onSubmit={submit}><h3>{capability.name}</h3>{Object.keys(fields).length === 0 && <p className="empty-state">此操作无需参数</p>}{Object.entries(fields).map(([name, field], index) => <label key={name} htmlFor={`operation-${name}`}><span>{typeof field.title === "string" ? field.title : name}{required.has(name) ? " *" : ""}</span>{controls[index]}{typeof field.description === "string" && <small>{field.description}</small>}</label>)}<button className="primary" disabled={disabled || unsupported.length > 0} type="submit">检查并确认</button></form>;
|
||||||
|
}
|
||||||
5
src/features/sessions/RecentEvents.tsx
Normal file
5
src/features/sessions/RecentEvents.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { Event } from "../../protocol/model";
|
||||||
|
|
||||||
|
export function RecentEvents({ events }: { events: readonly Event[] }) {
|
||||||
|
return <section><h3>最近事件</h3>{events.length === 0 ? <p className="empty-state">暂无事件</p> : <ol className="event-list">{events.map((event) => <li key={event.event_id}><strong>{event.type}</strong><time>{event.timestamp}</time></li>)}</ol>}</section>;
|
||||||
|
}
|
||||||
8
src/features/sessions/RunningTasks.tsx
Normal file
8
src/features/sessions/RunningTasks.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { Task, TaskId } from "../../protocol/model";
|
||||||
|
import type { AsyncViewState } from "./types";
|
||||||
|
|
||||||
|
export function RunningTasks({ tasks, state = "ready", onCancel }: { tasks: readonly Task[]; state?: AsyncViewState; onCancel?: (taskId: TaskId) => void }) {
|
||||||
|
if (state === "loading") return <section><h3>运行任务</h3><p role="status">正在加载…</p></section>;
|
||||||
|
if (state === "error") return <section><h3>运行任务</h3><p role="alert">任务加载失败</p></section>;
|
||||||
|
return <section><h3>运行任务</h3>{tasks.length === 0 ? <p className="empty-state">没有运行中的任务</p> : <ul className="card-list">{tasks.map((task) => <li key={task.task_id}><div><strong>{task.capability_id}</strong><small>{task.state} · {task.updated_at}</small></div>{task.cancellable && onCancel && <button onClick={() => onCancel(task.task_id)}>取消</button>}</li>)}</ul>}</section>;
|
||||||
|
}
|
||||||
19
src/features/sessions/SessionIdentity.tsx
Normal file
19
src/features/sessions/SessionIdentity.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Session } from "../../protocol/model";
|
||||||
|
|
||||||
|
const standardKeys = new Set(["server_id", "project_id", "session_id", "name", "state", "hostname", "username", "os", "architecture", "source_address", "first_seen_at", "last_active_at", "tags", "capabilities"]);
|
||||||
|
|
||||||
|
function display(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionIdentity({ session }: { session: Session }) {
|
||||||
|
const fields = [
|
||||||
|
["Session ID", session.session_id], ["状态", session.state], ["主机", session.hostname],
|
||||||
|
["用户", session.username], ["系统 / 架构", [session.os, session.architecture].filter(Boolean).join(" / ")],
|
||||||
|
["来源地址", session.source_address], ["首次出现", session.first_seen_at], ["最后活动", session.last_active_at],
|
||||||
|
] as const;
|
||||||
|
const extensions = Object.entries(session).filter(([key]) => !standardKeys.has(key));
|
||||||
|
return <section aria-labelledby="session-identity"><h3 id="session-identity">身份信息</h3><dl className="detail-grid">{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{display(value)}</dd></div>)}</dl>{extensions.length > 0 && <details><summary>扩展字段 ({extensions.length})</summary><dl className="detail-grid">{extensions.map(([key, value]) => <div key={key}><dt>{key}</dt><dd>{display(value)}</dd></div>)}</dl></details>}</section>;
|
||||||
|
}
|
||||||
3
src/features/sessions/SessionNotes.tsx
Normal file
3
src/features/sessions/SessionNotes.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function SessionNotes({ notes }: { notes?: string }) {
|
||||||
|
return <section><h3>服务端备注</h3><p className={notes ? "" : "empty-state"}>{notes || "暂无备注"}</p></section>;
|
||||||
|
}
|
||||||
13
src/features/sessions/SessionOverview.tsx
Normal file
13
src/features/sessions/SessionOverview.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { Session, TaskId } from "../../protocol/model";
|
||||||
|
import { CapabilityPanel } from "./CapabilityPanel";
|
||||||
|
import { RecentEvents } from "./RecentEvents";
|
||||||
|
import { RunningTasks } from "./RunningTasks";
|
||||||
|
import { SessionIdentity } from "./SessionIdentity";
|
||||||
|
import { SessionNotes } from "./SessionNotes";
|
||||||
|
import { TagEditor } from "./TagEditor";
|
||||||
|
import type { SessionOverviewData } from "./types";
|
||||||
|
|
||||||
|
export function SessionOverview({ session, data, onTagsChange, onCancelTask }: { session: Session; data: SessionOverviewData; onTagsChange?: (tags: readonly string[]) => void; onCancelTask?: (taskId: TaskId) => void }) {
|
||||||
|
const running = data.tasks.filter((task) => task.state === "PENDING" || task.state === "RUNNING");
|
||||||
|
return <div className="session-overview"><header><div><p className="eyebrow">SESSION OVERVIEW</p><h2>{session.name}</h2></div><span className={`connection ${session.state.toLowerCase()}`}>{session.state}</span></header><div className="overview-grid"><SessionIdentity session={session} /><CapabilityPanel declared={session.capabilities} catalog={data.capabilities} /><RunningTasks tasks={running} onCancel={onCancelTask} /><RecentEvents events={data.events} /><section><h3>Artifact</h3>{data.artifacts.length === 0 ? <p className="empty-state">暂无 Artifact</p> : <ul className="card-list">{data.artifacts.map((artifact) => <li key={artifact.artifact_id}><div><strong>{artifact.name}</strong><small>{artifact.media_type} · {artifact.size_bytes} bytes</small></div></li>)}</ul>}</section><SessionNotes notes={data.notes} /><TagEditor tags={session.tags} disabled={!onTagsChange} onChange={(tags) => onTagsChange?.(tags)} /></div></div>;
|
||||||
|
}
|
||||||
7
src/features/sessions/TagEditor.tsx
Normal file
7
src/features/sessions/TagEditor.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function TagEditor({ tags, disabled, onChange }: { tags: readonly string[]; disabled?: boolean; onChange: (tags: readonly string[]) => void }) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const add = () => { const tag = value.trim(); if (tag && !tags.includes(tag)) onChange([...tags, tag]); setValue(""); };
|
||||||
|
return <section><h3>标签</h3><div className="tag-editor">{tags.map((tag) => <button disabled={disabled} aria-label={`移除标签 ${tag}`} key={tag} onClick={() => onChange(tags.filter((item) => item !== tag))}>{tag} ×</button>)}<input disabled={disabled} aria-label="新标签" value={value} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); add(); } }} /><button disabled={disabled || !value.trim()} onClick={add}>添加</button></div></section>;
|
||||||
|
}
|
||||||
12
src/features/sessions/index.ts
Normal file
12
src/features/sessions/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./SessionOverview";
|
||||||
|
export * from "./SessionIdentity";
|
||||||
|
export * from "./CapabilityPanel";
|
||||||
|
export * from "./RunningTasks";
|
||||||
|
export * from "./RecentEvents";
|
||||||
|
export * from "./SessionNotes";
|
||||||
|
export * from "./TagEditor";
|
||||||
|
export * from "./GroupTree";
|
||||||
|
export * from "./GroupAssignment";
|
||||||
|
export * from "./OperationForm";
|
||||||
|
export * from "./OperationConfirmation";
|
||||||
65
src/features/sessions/sessions.test.tsx
Normal file
65
src/features/sessions/sessions.test.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Capability, CapabilityId, GroupId, ProjectId, ServerId, SessionGroup, SessionId } from "../../protocol/model";
|
||||||
|
import { CapabilityPanel } from "./CapabilityPanel";
|
||||||
|
import { GroupAssignment } from "./GroupAssignment";
|
||||||
|
import { OperationConfirmation } from "./OperationConfirmation";
|
||||||
|
import { OperationForm } from "./OperationForm";
|
||||||
|
import type { OperationIntent } from "./types";
|
||||||
|
|
||||||
|
const serverId = "server-1" as ServerId;
|
||||||
|
const projectId = "project-1" as ProjectId;
|
||||||
|
const sessionId = "session-1" as SessionId;
|
||||||
|
const capabilityId = "shell.safe" as CapabilityId;
|
||||||
|
const capability: Capability = {
|
||||||
|
server_id: serverId, project_id: projectId, capability_id: capabilityId,
|
||||||
|
name: "安全操作", supports_batch: true,
|
||||||
|
input_schema: { type: "object", required: ["path"], properties: { path: { type: "string", title: "路径", maxLength: 20 }, retries: { type: "integer", minimum: 0, maximum: 3 }, mode: { type: "string", enum: ["read", "list"] }, audit: { type: "boolean" } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Session capability 操作", () => {
|
||||||
|
it("只显示 Session 已声明的 capability", () => {
|
||||||
|
render(<CapabilityPanel declared={[capabilityId]} catalog={[capability, { ...capability, capability_id: "hidden" as CapabilityId, name: "未声明操作" }]} />);
|
||||||
|
expect(screen.getByText("安全操作")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("未声明操作")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("从受约束 Schema 生成类型化参数", () => {
|
||||||
|
const submit = vi.fn();
|
||||||
|
render(<OperationForm capability={capability} onSubmit={submit} />);
|
||||||
|
fireEvent.change(screen.getByLabelText("路径 *"), { target: { value: "/tmp/report" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("retries"), { target: { value: "2" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("mode"), { target: { value: "list" } });
|
||||||
|
fireEvent.click(screen.getByLabelText("audit"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "检查并确认" }));
|
||||||
|
expect(submit).toHaveBeenCalledWith({ path: "/tmp/report", retries: 2, mode: "list", audit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝渲染可执行或复合的未知 Schema 类型", () => {
|
||||||
|
render(<OperationForm capability={{ ...capability, input_schema: { properties: { payload: { type: "object" } } } }} onSubmit={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("不支持");
|
||||||
|
expect(screen.getByRole("button", { name: "检查并确认" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("上下文与分组命令", () => {
|
||||||
|
it("确认页展示锁定上下文、目标数量并脱敏参数", () => {
|
||||||
|
const intent: OperationIntent = { type: "submit-operation", capabilityId, context: { serverId, serverName: "Red", projectId, projectName: "Alpha", sessionIds: [sessionId] }, selection: { kind: "explicit", sessionIds: [sessionId] }, parameters: { path: "/tmp", access_token: "never-show" } };
|
||||||
|
render(<OperationConfirmation intent={intent} onConfirm={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
expect(screen.getByText(/Red \(server-1\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Alpha \(project-1\)/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 个明确 Session")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/never-show/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/••••••/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("跨页选择保持服务端 query 范围,不推算本地 ID", () => {
|
||||||
|
const onIntent = vi.fn();
|
||||||
|
const groups: SessionGroup[] = [{ server_id: serverId, project_id: projectId, group_id: "group-1" as GroupId, name: "生产", total_count: 120, online_count: 80 }];
|
||||||
|
render(<GroupAssignment projectId={projectId} groups={groups} selection={{ kind: "query", queryId: "query-abc" }} onIntent={onIntent} />);
|
||||||
|
expect(screen.getByText(/数量待服务端确认/)).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText("目标分组"), { target: { value: "group-1" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "生成移动命令" }));
|
||||||
|
expect(onIntent).toHaveBeenCalledWith({ type: "assign-sessions", projectId, selection: { kind: "query", queryId: "query-abc" }, groupId: "group-1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/features/sessions/types.ts
Normal file
45
src/features/sessions/types.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type {
|
||||||
|
Artifact,
|
||||||
|
Capability,
|
||||||
|
CapabilityId,
|
||||||
|
Event,
|
||||||
|
GroupId,
|
||||||
|
ProjectId,
|
||||||
|
ServerId,
|
||||||
|
SessionId,
|
||||||
|
Task,
|
||||||
|
} from "../../protocol/model";
|
||||||
|
|
||||||
|
export type AsyncViewState = "loading" | "empty" | "ready" | "pending" | "success" | "error" | "cancelled";
|
||||||
|
|
||||||
|
export interface LockedOperationContext {
|
||||||
|
readonly serverId: ServerId;
|
||||||
|
readonly serverName: string;
|
||||||
|
readonly projectId: ProjectId;
|
||||||
|
readonly projectName: string;
|
||||||
|
readonly sessionIds: readonly SessionId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionSelection =
|
||||||
|
| { readonly kind: "explicit"; readonly sessionIds: readonly SessionId[] }
|
||||||
|
| { readonly kind: "query"; readonly queryId: string; readonly estimatedCount?: number };
|
||||||
|
|
||||||
|
export interface OperationIntent {
|
||||||
|
readonly type: "submit-operation";
|
||||||
|
readonly context: LockedOperationContext;
|
||||||
|
readonly capabilityId: CapabilityId;
|
||||||
|
readonly selection: SessionSelection;
|
||||||
|
readonly parameters: Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupCommandIntent =
|
||||||
|
| { readonly type: "assign-sessions"; readonly projectId: ProjectId; readonly selection: SessionSelection; readonly groupId?: GroupId }
|
||||||
|
| { readonly type: "move-group"; readonly projectId: ProjectId; readonly groupId: GroupId; readonly parentGroupId?: GroupId };
|
||||||
|
|
||||||
|
export interface SessionOverviewData {
|
||||||
|
readonly tasks: readonly Task[];
|
||||||
|
readonly events: readonly Event[];
|
||||||
|
readonly artifacts: readonly Artifact[];
|
||||||
|
readonly capabilities: readonly Capability[];
|
||||||
|
readonly notes?: string;
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export type EventId = Brand<string, "EventId">;
|
|||||||
export type ArtifactId = Brand<string, "ArtifactId">;
|
export type ArtifactId = Brand<string, "ArtifactId">;
|
||||||
export type AuditId = Brand<string, "AuditId">;
|
export type AuditId = Brand<string, "AuditId">;
|
||||||
export type CapabilityId = Brand<string, "CapabilityId">;
|
export type CapabilityId = Brand<string, "CapabilityId">;
|
||||||
|
export type GroupId = Brand<string, "GroupId">;
|
||||||
|
|
||||||
export type Timestamp = Brand<string, "Timestamp">;
|
export type Timestamp = Brand<string, "Timestamp">;
|
||||||
export type Cursor = Brand<string, "Cursor">;
|
export type Cursor = Brand<string, "Cursor">;
|
||||||
@ -93,6 +94,15 @@ export interface Session extends SessionContext, Extensible {
|
|||||||
readonly capabilities: readonly CapabilityId[];
|
readonly capabilities: readonly CapabilityId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionGroup extends ProjectContext, Extensible {
|
||||||
|
readonly group_id: GroupId;
|
||||||
|
readonly parent_group_id?: GroupId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly total_count: number;
|
||||||
|
readonly online_count: number;
|
||||||
|
readonly children?: readonly SessionGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export type JsonSchema = Readonly<Record<string, unknown>>;
|
export type JsonSchema = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
export interface Capability extends ProjectContext, Extensible {
|
export interface Capability extends ProjectContext, Extensible {
|
||||||
|
|||||||
@ -99,6 +99,18 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
|||||||
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
||||||
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
|
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
|
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
|
||||||
|
.session-overview > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
||||||
|
.session-overview h2, .session-overview h3 { margin: 0; }
|
||||||
|
.overview-grid { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 12px; }
|
||||||
|
.overview-grid > section, .operation-form, .operation-confirmation, .group-tree { border: 1px solid var(--border); border-radius: 5px; background: #15191f; padding: 14px; }
|
||||||
|
.overview-grid h3, .operation-form h3, .operation-confirmation h3 { font-size: 12px; margin-bottom: 12px; }
|
||||||
|
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(120px, 1fr)); gap: 10px; margin: 0; }
|
||||||
|
.detail-grid dt { color: var(--muted); font-size: 10px; }.detail-grid dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||||
|
.card-list, .event-list, .group-tree ul { list-style: none; padding: 0; margin: 0; }.card-list li, .event-list li { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }.card-list small, .event-list time { display: block; color: var(--muted); font-size: 10px; margin-top: 3px; }
|
||||||
|
.empty-state { color: var(--muted); }.tag-editor { display: flex; flex-wrap: wrap; gap: 6px; }.tag-editor input { min-width: 100px; background: #111419; border: 1px solid var(--border); }
|
||||||
|
.group-tree li > div { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; }.group-tree button, .operation-form button, .operation-confirmation button { border: 1px solid var(--border); background: #1b2027; border-radius: 4px; padding: 5px 8px; }.group-tree .selected { background: var(--selected); }
|
||||||
|
.operation-form { display: grid; gap: 12px; }.operation-form label { display: grid; gap: 5px; }.operation-form input, .operation-form select, select { background: #111419; border: 1px solid var(--border); border-radius: 4px; padding: 7px; }.operation-form small { color: var(--muted); }.operation-confirmation pre { max-height: 220px; overflow: auto; background: #0f1115; padding: 10px; }.operation-confirmation > div:last-child { display: flex; justify-content: flex-end; gap: 7px; }
|
||||||
|
@media (max-width: 1050px) { .overview-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
|
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
|
||||||
.connection-list { grid-row: span 3; }
|
.connection-list { grid-row: span 3; }
|
||||||
@ -116,7 +128,7 @@ input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
|||||||
.record-actions { grid-column: 1 / -1; display: flex; gap: 6px; justify-content: flex-end; }
|
.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; }
|
.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; }
|
.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); }
|
.connection-list .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; }
|
.import-panel textarea { width: 100%; min-height: 130px; margin: 10px 0; }
|
||||||
.diagnostics > button { 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 { display: grid; grid-template-columns: 110px 1fr; padding: 6px 0; border-top: 1px solid var(--border); font-size: 10px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user