diff --git a/src/features/sessions/CapabilityPanel.tsx b/src/features/sessions/CapabilityPanel.tsx new file mode 100644 index 0000000..78a3e41 --- /dev/null +++ b/src/features/sessions/CapabilityPanel.tsx @@ -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

Capability

{visible.length === 0 ?

服务端未声明可用操作

: }
; +} diff --git a/src/features/sessions/GroupAssignment.tsx b/src/features/sessions/GroupAssignment.tsx new file mode 100644 index 0000000..190367f --- /dev/null +++ b/src/features/sessions/GroupAssignment.tsx @@ -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(""); + const count = selection.kind === "explicit" ? selection.sessionIds.length : selection.estimatedCount; + return

移动 Session

目标:{count === undefined ? "服务端查询范围(数量待服务端确认)" : `${count} 个 Session`}

; +} diff --git a/src/features/sessions/GroupTree.tsx b/src/features/sessions/GroupTree.tsx new file mode 100644 index 0000000..713eccf --- /dev/null +++ b/src/features/sessions/GroupTree.tsx @@ -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>(new Set()); + const render = (group: SessionGroup, depth: number) => { + const hasChildren = Boolean(group.children?.length); + const isCollapsed = collapsed.has(group.group_id); + return
  • + + {group.online_count} / {group.total_count} +
    {hasChildren && !isCollapsed &&
      {group.children?.map((child) => render(child, depth + 1))}
    }
  • ; + }; + return ; +} diff --git a/src/features/sessions/OperationConfirmation.tsx b/src/features/sessions/OperationConfirmation.tsx new file mode 100644 index 0000000..3c3ae04 --- /dev/null +++ b/src/features/sessions/OperationConfirmation.tsx @@ -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

    确认服务端操作

    ◆ LOCKED CONTEXT

    Teamserver
    {context.serverName} ({context.serverId})
    Project
    {context.projectName} ({context.projectId})
    Operation
    {intent.capabilityId}
    目标
    {target}

    参数摘要

    {JSON.stringify(Object.fromEntries(Object.entries(intent.parameters).map(([key, value]) => [key, redact(key, value)])), null, 2)}
    ; +} diff --git a/src/features/sessions/OperationForm.tsx b/src/features/sessions/OperationForm.tsx new file mode 100644 index 0000000..7c01da0 --- /dev/null +++ b/src/features/sessions/OperationForm.tsx @@ -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 { + const value = schema.properties; + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +export function OperationForm({ capability, disabled, onSubmit }: { capability: Capability; disabled?: boolean; onSubmit: (parameters: Readonly>) => 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>(() => 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 ; + if (field.type === "boolean") return setValues({ ...values, [name]: e.currentTarget.checked })} />; + if (field.type === "number" || field.type === "integer") return setValues({ ...values, [name]: e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value) })} />; + if (field.type === "string" || field.type === undefined) return setValues({ ...values, [name]: e.currentTarget.value })} />; + return

    字段 {name} 使用不支持的 Schema 类型

    ; + }); + return

    {capability.name}

    {Object.keys(fields).length === 0 &&

    此操作无需参数

    }{Object.entries(fields).map(([name, field], index) => )}
    ; +} diff --git a/src/features/sessions/RecentEvents.tsx b/src/features/sessions/RecentEvents.tsx new file mode 100644 index 0000000..9456c8e --- /dev/null +++ b/src/features/sessions/RecentEvents.tsx @@ -0,0 +1,5 @@ +import type { Event } from "../../protocol/model"; + +export function RecentEvents({ events }: { events: readonly Event[] }) { + return

    最近事件

    {events.length === 0 ?

    暂无事件

    :
      {events.map((event) =>
    1. {event.type}
    2. )}
    }
    ; +} diff --git a/src/features/sessions/RunningTasks.tsx b/src/features/sessions/RunningTasks.tsx new file mode 100644 index 0000000..a8ccebd --- /dev/null +++ b/src/features/sessions/RunningTasks.tsx @@ -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

    运行任务

    正在加载…

    ; + if (state === "error") return

    运行任务

    任务加载失败

    ; + return

    运行任务

    {tasks.length === 0 ?

    没有运行中的任务

    :
      {tasks.map((task) =>
    • {task.capability_id}{task.state} · {task.updated_at}
      {task.cancellable && onCancel && }
    • )}
    }
    ; +} diff --git a/src/features/sessions/SessionIdentity.tsx b/src/features/sessions/SessionIdentity.tsx new file mode 100644 index 0000000..014c8f4 --- /dev/null +++ b/src/features/sessions/SessionIdentity.tsx @@ -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

    身份信息

    {fields.map(([label, value]) =>
    {label}
    {display(value)}
    )}
    {extensions.length > 0 &&
    扩展字段 ({extensions.length})
    {extensions.map(([key, value]) =>
    {key}
    {display(value)}
    )}
    }
    ; +} diff --git a/src/features/sessions/SessionNotes.tsx b/src/features/sessions/SessionNotes.tsx new file mode 100644 index 0000000..e3086f3 --- /dev/null +++ b/src/features/sessions/SessionNotes.tsx @@ -0,0 +1,3 @@ +export function SessionNotes({ notes }: { notes?: string }) { + return

    服务端备注

    {notes || "暂无备注"}

    ; +} diff --git a/src/features/sessions/SessionOverview.tsx b/src/features/sessions/SessionOverview.tsx new file mode 100644 index 0000000..a2a9a9e --- /dev/null +++ b/src/features/sessions/SessionOverview.tsx @@ -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

    SESSION OVERVIEW

    {session.name}

    {session.state}

    Artifact

    {data.artifacts.length === 0 ?

    暂无 Artifact

    :
      {data.artifacts.map((artifact) =>
    • {artifact.name}{artifact.media_type} · {artifact.size_bytes} bytes
    • )}
    }
    onTagsChange?.(tags)} />
    ; +} diff --git a/src/features/sessions/TagEditor.tsx b/src/features/sessions/TagEditor.tsx new file mode 100644 index 0000000..7a591b5 --- /dev/null +++ b/src/features/sessions/TagEditor.tsx @@ -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

    标签

    {tags.map((tag) => )} setValue(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); add(); } }} />
    ; +} diff --git a/src/features/sessions/index.ts b/src/features/sessions/index.ts new file mode 100644 index 0000000..64b2bc6 --- /dev/null +++ b/src/features/sessions/index.ts @@ -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"; diff --git a/src/features/sessions/sessions.test.tsx b/src/features/sessions/sessions.test.tsx new file mode 100644 index 0000000..1d2517c --- /dev/null +++ b/src/features/sessions/sessions.test.tsx @@ -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(); + expect(screen.getByText("安全操作")).toBeInTheDocument(); + expect(screen.queryByText("未声明操作")).not.toBeInTheDocument(); + }); + + it("从受约束 Schema 生成类型化参数", () => { + const submit = vi.fn(); + render(); + 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(); + 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(); + 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(); + 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" }); + }); +}); diff --git a/src/features/sessions/types.ts b/src/features/sessions/types.ts new file mode 100644 index 0000000..5368720 --- /dev/null +++ b/src/features/sessions/types.ts @@ -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>; +} + +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; +} diff --git a/src/protocol/model.ts b/src/protocol/model.ts index 0e6783d..58594ef 100644 --- a/src/protocol/model.ts +++ b/src/protocol/model.ts @@ -13,6 +13,7 @@ export type EventId = Brand; export type ArtifactId = Brand; export type AuditId = Brand; export type CapabilityId = Brand; +export type GroupId = Brand; export type Timestamp = Brand; export type Cursor = Brand; @@ -93,6 +94,15 @@ export interface Session extends SessionContext, Extensible { 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>; export interface Capability extends ProjectContext, Extensible { diff --git a/src/styles.css b/src/styles.css index 67633f4..8b1cbbc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,7 +7,7 @@ } * { box-sizing: border-box; } body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); } -button, input { font: inherit; color: inherit; } +button, input, select { font: inherit; color: inherit; } button { cursor: pointer; } button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; } .shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); } @@ -98,3 +98,15 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; } 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 (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; } }