Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
- **i18n**:所有用户文案使用 `tr('中文', 'English')`,包含占位符、空态、错误、Tooltip 和可访问名称;不在同一字符串中英并排,检查较长英文与长选项是否挤压布局。
- **验收与维护**:按设计规范末尾清单检查布局、交互、窄屏、中英文及明暗主题;视觉改动提交前必须通过真实浏览器截图实看,light / dark 各一张,纯文档变更无需截图。运行相关交互测试、构建与 lint,如实记录未验证项和既有失败。公共规则变更在同一 PR 更新设计规范与相关测试,避免页面各自演变。

- **表格操作列**:遵循设计规范「表格操作列」:按内容收紧,表头与按钮组左对齐;固定列复用 `.og-action-table` 不透明底色。验证横向滚动、深浅主题和不同权限下的按钮数量,不能仅检查满按钮状态。

### API
- 所有 API 变更先更新 `.proto`,禁止改生成代码
- Handler 必须有 Swagger 注释:`@Summary`、`@Router`、`@Success` 缺一不可
Expand Down
6 changes: 6 additions & 0 deletions docs/design/frontend-design-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
6. 资源清单可将筛选栏与表格放在同一 `Card`,表格复用 `.og-resource-table` 的表头、行距与悬停样式。列表与配置详情随内容区宽度伸展,页头与正文保持对齐,不以固定最大宽度造成宽屏两侧大面积留白;阅读宽度限制仅用于长文本或表单。已保存目标按列展示,新增操作保留在列表底部。发现结果选择使用 `Radio` 配合 `.og-choice-row`,整行 `label` 可点击,选中和禁用状态保持一致;已配置范围明确标注,不能只显示一个不可点击的单选框。
7. 采集配置详情的表格使用 `.og-resource-table[data-variant="quiet"]`:表头与正文共用卡片底色,仅保留淡化的行间分隔;卡片标题和底部新增入口通过间距区分,不重复添加横线。

### 表格操作列

- 操作列表头与按钮组统一左对齐,按内容收紧;自动布局使用 `w-px` 配合 `whitespace-nowrap`,不为预估的按钮数量写死 320/340px 宽度。按钮过多时收进公共下拉菜单,不扩大操作列挤压信息列。
- 带固定操作列的表格使用 `.og-action-table`,固定单元格保留 `sticky right-0` 与层级。普通行和固定列共用不透明底色,表头也保持一致;不能用透明固定列透出下面的文字,也不能独立选一个更亮的底色。
- 验证深浅主题、默认与悬停状态、横向滚动、中英文、不同权限与按钮数量。新增选中态时必须同步整行与固定单元格底色。

## 配色、文字与图标

| 用途 | 约定 |
Expand Down
49 changes: 45 additions & 4 deletions web/src/components/XTerminal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import { act, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { XTerminal } from './XTerminal';
import { XTerminal, type XTerminalApi } from './XTerminal';
import { setThemePreference } from '@/store/mode';

const terminalMock = vi.hoisted(() => ({
instances: [] as Array<{ options: { theme?: { background?: string; foreground?: string; white?: string } } }>,
dimensions: { cols: 132, rows: 42 },
}));

vi.mock('xterm', () => ({
Terminal: class Terminal {
cols = 80;
rows = 24;
resizeListener?: () => void;
options: { theme?: { background?: string; foreground?: string; white?: string } };

constructor(options: { theme?: { background?: string; foreground?: string; white?: string } }) {
this.options = options;
terminalMock.instances.push(this);
}

loadAddon() {}
loadAddon(addon: { activate?(terminal: Terminal): void }) { addon.activate?.(this); }
open() {}
onData() { return { dispose() {} }; }
onResize() { return { dispose() {} }; }
onResize(listener: () => void) { this.resizeListener = listener; return { dispose() {} }; }
resize(cols: number, rows: number) {
if (this.cols === cols && this.rows === rows) return;
this.cols = cols;
this.rows = rows;
this.resizeListener?.();
}
attachCustomKeyEventHandler() {}
write() {}
writeln() {}
Expand All @@ -31,7 +41,11 @@ vi.mock('xterm', () => ({
}));

vi.mock('xterm-addon-fit', () => ({
FitAddon: class FitAddon { fit() {} },
FitAddon: class FitAddon {
terminal?: { resize(cols: number, rows: number): void };
activate(terminal: { resize(cols: number, rows: number): void }) { this.terminal = terminal; }
fit() { this.terminal?.resize(terminalMock.dimensions.cols, terminalMock.dimensions.rows); }
},
}));

vi.mock('xterm-addon-web-links', () => ({
Expand All @@ -46,9 +60,36 @@ vi.stubGlobal('ResizeObserver', class ResizeObserver {
describe('XTerminal', () => {
beforeEach(() => {
terminalMock.instances.length = 0;
terminalMock.dimensions = { cols: 132, rows: 42 };
localStorage.clear();
});

it('reports initial fitted dimensions before any observer callback', () => {
const onResize = vi.fn();
render(<XTerminal attachRef={() => {}} onResize={onResize} />);
expect(onResize.mock.calls).toEqual([[132, 42]]);
});

it('reports the initial default grid even without an xterm resize event', () => {
terminalMock.dimensions = { cols: 80, rows: 24 };
const onResize = vi.fn();
render(<XTerminal attachRef={() => {}} onResize={onResize} />);
expect(onResize).toHaveBeenCalledWith(80, 24);
});

it('explicit fit re-publishes unchanged dimensions for reconnect', () => {
const onResize = vi.fn();
let api!: XTerminalApi;
render(<XTerminal attachRef={(value) => { api = value; }} onResize={onResize} />);
onResize.mockClear();
act(() => api.fit());
expect(onResize.mock.calls).toEqual([[132, 42]]);
terminalMock.dimensions = { cols: 100, rows: 30 };
onResize.mockClear();
act(() => api.fit());
expect(onResize.mock.calls).toEqual([[100, 30]]);
});

it('只读日志启用应用主题后会响应 light 和 dark 切换', () => {
setThemePreference('light');
const { container } = render(<XTerminal attachRef={() => {}} readOnly followAppTheme />);
Expand Down
54 changes: 27 additions & 27 deletions web/src/components/XTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,26 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class

term.open(el);
terminalRef.current = term;
// Initial fit must happen after open() lays out the DOM.
try {
fitAddon.fit();
} catch {
/* container not laid out yet — ResizeObserver will catch up */
}

const dataDisposable = readOnly ? null : term.onData((d) => onData?.(d));
const resizeDisposable = term.onResize(({ cols, rows }) => {
let lastSize: { cols: number; rows: number } | undefined;
const publishSize = () => {
const { cols, rows } = term;
if (lastSize?.cols === cols && lastSize.rows === rows) return;
lastSize = { cols, rows };
onResize?.(cols, rows);
});
};
const dataDisposable = readOnly ? null : term.onData((d) => onData?.(d));
const resizeDisposable = term.onResize(publishSize);
const fit = (forceReport = false) => {
if (forceReport) lastSize = undefined;
try {
fitAddon.fit();
publishSize();
} catch {
/* container not laid out yet — ResizeObserver will catch up */
}
};
// Subscribe before fitting, including grids that stay at xterm's default size.
fit();

if (readOnly) {
term.attachCustomKeyEventHandler((event) => {
Expand All @@ -150,17 +159,12 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class
});
}

// Re-fit on container size changes (sidebar collapse, window resize).
// We debounce nothing — fit() is cheap and the resize control frame
// is throttled by SSH itself.
const ro = new ResizeObserver(() => {
try {
fitAddon.fit();
} catch {
/* dom temporarily detached during route change */
}
});
const ro = new ResizeObserver(() => fit());
ro.observe(el);
let disposed = false;
void document.fonts?.ready.then(() => {
if (!disposed) fit();
});

// Hand the imperative API back to the parent. The decoder is created
// once and reused so we don't churn allocations per inbound chunk.
Expand All @@ -179,13 +183,8 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class
writeln: (line) => term.writeln(line),
clear: () => term.clear(),
focus: () => term.focus(),
fit: () => {
try {
fitAddon.fit();
} catch {
/* noop */
}
},
// A new SSH session must receive dimensions even when the grid is unchanged.
fit: () => fit(true),
dispose: () => term.dispose(),
};
attachRef(api);
Expand All @@ -195,6 +194,7 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class
if (!readOnly) term.focus();

return () => {
disposed = true;
terminalRef.current = null;
ro.disconnect();
dataDisposable?.dispose();
Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/Clusters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ describe("device cluster pages", () => {
name: "bare-metal-prod",
});
expect(clusterLink).toHaveAttribute("href", "/clusters/501");
expect(screen.getByRole('columnheader', { name: '操作' })).toHaveClass('w-px', 'text-left');
expect(clusterLink.closest('table')).toHaveClass('table-auto');
expect(screen.getByRole("link", { name: "k8s-prod" })).toHaveAttribute("href", "/clusters/901");
expect(screen.getByText("接入类型")).toBeInTheDocument();
expect(screen.getByText("Host")).toBeInTheDocument();
Expand Down
6 changes: 3 additions & 3 deletions web/src/pages/Clusters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export default function ClustersPage() {
/>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] table-fixed whitespace-nowrap text-left text-xs">
<table className="w-full min-w-[1000px] table-auto whitespace-nowrap text-left text-xs">
<thead className="border-b border-zinc-800/60 bg-zinc-950/30 text-[11px] uppercase tracking-wide text-zinc-500">
<tr>
<th className="w-[22%] px-4 py-2.5 font-medium">
Expand All @@ -299,7 +299,7 @@ export default function ClustersPage() {
<th className="px-4 py-2.5 font-medium">
{tr("更新时间", "Updated")}
</th>
<th className="w-[320px] px-4 py-2.5 text-right font-medium">
<th className="w-px px-4 py-2.5 text-left font-medium">
{tr("操作", "Actions")}
</th>
</tr>
Expand Down Expand Up @@ -438,7 +438,7 @@ function ClusterRow({
<td className="whitespace-nowrap px-4 py-3 text-zinc-500">
{relativeTime(summary.cluster.updated_at)}
</td>
<td className="px-4 py-3 text-right" onClick={(event) => event.stopPropagation()}>
<td className="w-px px-4 py-3 text-left" onClick={(event) => event.stopPropagation()}>
<div className="inline-flex items-center gap-1">
<Link
to={`/clusters/${summary.cluster.id}`}
Expand Down
40 changes: 39 additions & 1 deletion web/src/pages/DeviceShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ConnectModal, DeviceShell } from './DeviceShell';
import { server } from '@/test/msw-server';
import { useEffect } from 'react';

const openShellSocket = vi.hoisted(() => vi.fn());

vi.mock('@/components/XTerminal', () => ({ XTerminal: () => null }));
const terminalSize = vi.hoisted(() => ({ cols: 132, rows: 42 }));
vi.mock('@/components/XTerminal', () => ({ XTerminal: ({ attachRef, onResize }: {
attachRef(api: Partial<import('@/components/XTerminal').XTerminalApi>): void;
onResize(cols: number, rows: number): void;
}) => {
useEffect(() => {
attachRef({ fit: () => onResize(terminalSize.cols, terminalSize.rows), write: vi.fn() });
}, [attachRef, onResize]);
return null;
} }));
vi.mock('@/api/webshell', async () => ({
...await vi.importActual<typeof import('@/api/webshell')>('@/api/webshell'),
openShellSocket,
Expand Down Expand Up @@ -39,6 +49,8 @@ describe('ConnectModal', () => {
beforeEach(() => {
localStorage.setItem('ongrid-locale', 'zh-CN');
openShellSocket.mockReset();
terminalSize.cols = 132;
terminalSize.rows = 42;
server.use(
http.get('/api/v1/edges', () => HttpResponse.json({ items: [edge], total: 1 })),
http.get('/api/v1/edges/70', () => HttpResponse.json(edge)),
Expand All @@ -54,6 +66,32 @@ describe('ConnectModal', () => {
vi.restoreAllMocks();
});

it('sends measured open size first and re-synchronizes unchanged size on ready and reconnect', async () => {
const first = { readyState: WebSocket.OPEN, send: vi.fn(), close: vi.fn() } as unknown as WebSocket;
const second = { readyState: WebSocket.OPEN, send: vi.fn(), close: vi.fn() } as unknown as WebSocket;
openShellSocket.mockReturnValueOnce(first).mockReturnValueOnce(second);
renderShell();
fireEvent.change(await screen.findByLabelText('密码'), { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await waitFor(() => expect(openShellSocket).toHaveBeenCalledOnce());
act(() => { first.onopen?.(new Event('open')); });
expect(first.send).toHaveBeenCalledTimes(1);
expect(first.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'open', cols: 132, rows: 42, term: 'xterm-256color' }));
terminalSize.rows = 38;
await act(async () => { await first.onmessage?.({ data: JSON.stringify({ type: 'ready' }) } as MessageEvent); });
expect(first.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'resize', cols: 132, rows: 38 }));

fireEvent.click(screen.getByRole('button', { name: '重新连接' }));
fireEvent.change(await screen.findByLabelText('密码'), { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await waitFor(() => expect(openShellSocket).toHaveBeenCalledTimes(2));
act(() => { second.onopen?.(new Event('open')); });
expect(second.send).toHaveBeenCalledTimes(1);
expect(second.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'open', cols: 132, rows: 38, term: 'xterm-256color' }));
await act(async () => { await second.onmessage?.({ data: JSON.stringify({ type: 'ready' }) } as MessageEvent); });
expect(second.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'resize', cols: 132, rows: 38 }));
});

it('先展示已保存账户,新增账户时直接连接并延后保存', async () => {
server.use(
http.get('/api/v1/devices/42/shell/credentials', () =>
Expand Down
14 changes: 10 additions & 4 deletions web/src/pages/DeviceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function DeviceShell() {
// outliving any single render is the whole point of this page.
const termRef = useRef<XTerminalApi | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const shellReadyRef = useRef(false);
// Latest cols/rows reported by xterm. We need them when sending the
// first `open` frame (called from a callback that doesn't have direct
// access to the terminal's geometry).
Expand Down Expand Up @@ -196,6 +197,7 @@ export function DeviceShell() {
// Tear down the socket. Caller decides whether to also dispose the
// terminal — usually we keep it so the user can read final output.
const teardown = useCallback(() => {
shellReadyRef.current = false;
sendCloseOnce();
const ws = wsRef.current;
wsRef.current = null;
Expand Down Expand Up @@ -275,6 +277,7 @@ export function DeviceShell() {

ws.onopen = () => {
if (wsRef.current !== ws) return;
termRef.current?.fit();
const { cols, rows } = sizeRef.current;
sendControl(ws, {
type: 'open',
Expand Down Expand Up @@ -302,6 +305,8 @@ export function DeviceShell() {
switch (frame.type) {
case 'ready': {
inputs.password = '';
shellReadyRef.current = true;
termRef.current?.fit();
setConn({ kind: 'open' });
writeBanner(ansiDim(tr(`-- SSH 已连接 (${inputs.user}@${edge?.name ?? deviceId}) --`, `-- SSH connected (${inputs.user}@${edge?.name ?? deviceId}) --`)));
break;
Expand Down Expand Up @@ -376,6 +381,7 @@ export function DeviceShell() {

ws.onclose = (ev) => {
if (wsRef.current !== ws) return;
shellReadyRef.current = false;
inputs.password = '';
if (!closedSentRef.current && ev.code === 1006) {
const message = tr('连接异常断开', 'Connection dropped unexpectedly');
Expand Down Expand Up @@ -416,7 +422,7 @@ export function DeviceShell() {
const onTermResize = useCallback((cols: number, rows: number) => {
sizeRef.current = { cols, rows };
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
if (shellReadyRef.current && ws && ws.readyState === WebSocket.OPEN) {
sendControl(ws, { type: 'resize', cols, rows });
}
}, []);
Expand Down Expand Up @@ -457,8 +463,8 @@ export function DeviceShell() {
extractHostname(edge?.host_info) || edge?.name || deviceId || tr('设备', 'device');

return (
<>{dialog}<main className="anim-fade flex flex-1 flex-col overflow-hidden bg-zinc-950">
<header className="flex items-center justify-between border-b border-zinc-800/60 bg-zinc-900/60 px-4 py-2">
<>{dialog}<main className="anim-fade flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-zinc-950">
<header className="flex shrink-0 items-center justify-between border-b border-zinc-800/60 bg-zinc-900/60 px-4 py-2">
<div className="flex min-w-0 items-center gap-2 text-xs text-zinc-300">
<TerminalIcon size={14} className="text-zinc-500" />
<span className="truncate font-medium text-zinc-100">{hostname}</span>
Expand Down Expand Up @@ -498,7 +504,7 @@ export function DeviceShell() {
</div>
</header>

<div className="flex-1 overflow-hidden p-2">
<div className="min-h-0 min-w-0 flex-1 overflow-hidden p-2">
<XTerminal
onData={onTermData}
onResize={onTermResize}
Expand Down
Loading
Loading