Compare commits

...

3 Commits

Author SHA1 Message Date
dev
a0c15fa127 Merge pull request '完善 Session 详情与能力操作组件' (#10) from agent/docker/f241e363 into main
Reviewed-on: #10
2026-08-03 19:29:56 +00:00
Ubuntu
cc7c072c57 Merge remote-tracking branch 'origin/main' into agent/docker/f241e363
# Conflicts:
#	src/styles.css
2026-08-03 19:28:44 +00:00
Ubuntu
212b53b923 完善 Session 详情与能力操作组件
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 19:25:59 +00:00
16 changed files with 269 additions and 1 deletions

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View 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>;
}

View File

@ -0,0 +1,3 @@
export function SessionNotes({ notes }: { notes?: string }) {
return <section><h3></h3><p className={notes ? "" : "empty-state"}>{notes || "暂无备注"}</p></section>;
}

View 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>;
}

View 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>;
}

View 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";

View 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" });
});
});

View 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;
}

View File

@ -13,6 +13,7 @@ export type EventId = Brand<string, "EventId">;
export type ArtifactId = Brand<string, "ArtifactId">;
export type AuditId = Brand<string, "AuditId">;
export type CapabilityId = Brand<string, "CapabilityId">;
export type GroupId = Brand<string, "GroupId">;
export type Timestamp = Brand<string, "Timestamp">;
export type Cursor = Brand<string, "Cursor">;
@ -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<Record<string, unknown>>;
export interface Capability extends ProjectContext, Extensible {

View File

@ -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; }
@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; } }
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
.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 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); }
.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; }
.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; }