feat(web): redesign fleet cards and instance workspace
This commit is contained in:
@@ -87,10 +87,8 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
'/instances/alpha/overview',
|
'/instances/alpha/overview',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
within(screen.getByRole('row', { name: /Bravo/ }))
|
within(screen.getByRole('row', { name: /Bravo/ })).getByText('http://bravo.example:8080'),
|
||||||
.getByRole('link', { name: '打开 Bravo 的源站' })
|
).toBeTruthy();
|
||||||
.getAttribute('href'),
|
|
||||||
).toBe('http://bravo.example:8080');
|
|
||||||
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
|
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
consoleError.mock.calls.some((call) =>
|
consoleError.mock.calls.some((call) =>
|
||||||
@@ -159,7 +157,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
expect(within(card).getByText('13800138000')).toBeTruthy();
|
expect(within(card).getByText('13800138000')).toBeTruthy();
|
||||||
expect(await within(card).findByText('收到')).toBeTruthy();
|
expect(await within(card).findByText('收到')).toBeTruthy();
|
||||||
expect(within(card).getByText('13900139000')).toBeTruthy();
|
expect(within(card).getByText('13900139000')).toBeTruthy();
|
||||||
expect(within(card).getByText(/这是一条用于聚合页展示/)).toBeTruthy();
|
expect(within(card).queryByText(/这是一条用于聚合页展示/)).toBeNull();
|
||||||
expect(within(card).getByText(/2026/)).toBeTruthy();
|
expect(within(card).getByText(/2026/)).toBeTruthy();
|
||||||
expect(within(card).queryByText('能力未知')).toBeNull();
|
expect(within(card).queryByText('能力未知')).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
@@ -248,6 +246,37 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the route owner without waiting for optional Fleet resource enrichment', async () => {
|
||||||
|
const instanceDataSource: InstanceDataSource = {
|
||||||
|
get: vi.fn().mockResolvedValue({
|
||||||
|
id: 'bravo',
|
||||||
|
name: 'Bravo',
|
||||||
|
origin: 'http://bravo.example:8080',
|
||||||
|
tags: [],
|
||||||
|
revision: 1,
|
||||||
|
credentialConfigured: false,
|
||||||
|
}),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
const fleetDataSource = source(() => new Promise<FleetSnapshot>(() => undefined));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppShell
|
||||||
|
pathname="/instances/bravo/overview"
|
||||||
|
instanceDataSource={instanceDataSource}
|
||||||
|
fleetDataSource={fleetDataSource}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { level: 1, name: 'Bravo' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('status', { name: '资源摘要状态' }).textContent).toContain(
|
||||||
|
'正在加载资源摘要',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
it('supports accessible search, status filtering, sorting, and visible selection', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
render(<AppShell pathname="/fleet" fleetDataSource={source(async () => snapshot)} />);
|
||||||
@@ -355,9 +384,8 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
|
expect(screen.getByRole('heading', { name: '短信管理' })).toBeTruthy();
|
||||||
expect(screen.getByText('Owner modem')).toBeTruthy();
|
expect(screen.getByText('Owner modem')).toBeTruthy();
|
||||||
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
|
expect(screen.getByText('源站已由控制平面托管')).toBeTruthy();
|
||||||
'https://owner.example',
|
expect(screen.queryByRole('link', { name: '打开源站' })).toBeNull();
|
||||||
);
|
|
||||||
|
|
||||||
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
|
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
|
||||||
expect(screen.queryByText('Owner modem')).toBeNull();
|
expect(screen.queryByText('Owner modem')).toBeNull();
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export interface InstanceContext {
|
|||||||
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
|
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
|
||||||
authentication: 'authenticated' | 'auth-required' | 'unknown';
|
authentication: 'authenticated' | 'auth-required' | 'unknown';
|
||||||
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
|
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
|
||||||
|
resources?: Readonly<{
|
||||||
|
cpuPercent?: number;
|
||||||
|
memoryPercent?: number;
|
||||||
|
maxTemperatureCelsius?: number;
|
||||||
|
phoneNumbers?: readonly string[];
|
||||||
|
}>;
|
||||||
|
resourceSummaryState?: 'loading' | 'ready' | 'unavailable';
|
||||||
}
|
}
|
||||||
export interface AppShellProps {
|
export interface AppShellProps {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
@@ -440,8 +447,13 @@ export function AppShell({
|
|||||||
setInstanceLoading(false);
|
setInstanceLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
let active = true;
|
let active = true;
|
||||||
setInstanceLoading(true);
|
setInstanceLoading(true);
|
||||||
|
const fleetLoad = (fleetDataSource ?? defaultFleetDataSource).load(controller.signal).then(
|
||||||
|
(fleet) => ({ ok: true as const, fleet }),
|
||||||
|
() => ({ ok: false as const }),
|
||||||
|
);
|
||||||
void resolvedInstanceDataSource.get(routeInstanceId).then(
|
void resolvedInstanceDataSource.get(routeInstanceId).then(
|
||||||
(owner) => {
|
(owner) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -457,8 +469,50 @@ export function AppShell({
|
|||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
authentication: 'unknown',
|
authentication: 'unknown',
|
||||||
freshness: 'unknown',
|
freshness: 'unknown',
|
||||||
|
resourceSummaryState: 'loading',
|
||||||
});
|
});
|
||||||
setInstanceLoading(false);
|
setInstanceLoading(false);
|
||||||
|
|
||||||
|
void fleetLoad.then((result) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (!result.ok) {
|
||||||
|
setLoadedInstance((current) =>
|
||||||
|
current?.id === routeInstanceId
|
||||||
|
? { ...current, resourceSummaryState: 'unavailable' }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fleet = result.fleet;
|
||||||
|
const fleetOwner = fleet.instances.find((candidate) => candidate.id === routeInstanceId);
|
||||||
|
const status = fleet.statuses.get(routeInstanceId);
|
||||||
|
const routeStatus = !status
|
||||||
|
? 'unknown'
|
||||||
|
: !status.reachable
|
||||||
|
? 'offline'
|
||||||
|
: status.authenticated === false
|
||||||
|
? 'auth-required'
|
||||||
|
: 'online';
|
||||||
|
const freshness = status?.summary?.freshness;
|
||||||
|
setLoadedInstance({
|
||||||
|
id: owner.id,
|
||||||
|
name: owner.name,
|
||||||
|
origin: fleetOwner?.url ?? owner.origin,
|
||||||
|
status: routeStatus,
|
||||||
|
authentication:
|
||||||
|
status?.authenticated === true
|
||||||
|
? 'authenticated'
|
||||||
|
: status?.authenticated === false
|
||||||
|
? 'auth-required'
|
||||||
|
: 'unknown',
|
||||||
|
freshness:
|
||||||
|
freshness === 'fresh' || freshness === 'stale' || freshness === 'expired'
|
||||||
|
? freshness
|
||||||
|
: 'unknown',
|
||||||
|
...(status?.summary?.resources ? { resources: status.summary.resources } : {}),
|
||||||
|
resourceSummaryState: status ? 'ready' : 'unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -468,8 +522,15 @@ export function AppShell({
|
|||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [instance, resolvedInstanceDataSource, routeInstanceId]);
|
}, [
|
||||||
|
defaultFleetDataSource,
|
||||||
|
fleetDataSource,
|
||||||
|
instance,
|
||||||
|
resolvedInstanceDataSource,
|
||||||
|
routeInstanceId,
|
||||||
|
]);
|
||||||
|
|
||||||
const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance;
|
const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance;
|
||||||
const refresh = useControlPlaneEvents(
|
const refresh = useControlPlaneEvents(
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ const snapshot: FleetSnapshot = {
|
|||||||
'alpha',
|
'alpha',
|
||||||
{
|
{
|
||||||
instanceId: 'alpha',
|
instanceId: 'alpha',
|
||||||
connectivity: 'online',
|
reachable: true,
|
||||||
authentication: 'authenticated',
|
authenticated: true,
|
||||||
freshness: 'fresh',
|
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
|
||||||
capabilities: ['overview', 'messages'],
|
|
||||||
anomalies: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
@@ -41,5 +39,16 @@ describe('FleetPage card navigation', () => {
|
|||||||
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
|
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
|
||||||
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
|
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
|
||||||
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
||||||
|
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
||||||
|
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
||||||
|
);
|
||||||
|
expect(within(card).getByRole('meter', { name: 'CPU 使用率' }).getAttribute('value')).toBe(
|
||||||
|
'24',
|
||||||
|
);
|
||||||
|
expect(within(card).getByRole('meter', { name: '内存使用率' }).getAttribute('value')).toBe(
|
||||||
|
'51',
|
||||||
|
);
|
||||||
|
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
||||||
|
expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ const messageDirection = (value: string): string =>
|
|||||||
: value === 'outgoing' || value === 'sent'
|
: value === 'outgoing' || value === 'sent'
|
||||||
? '发送'
|
? '发送'
|
||||||
: '未知方向';
|
: '未知方向';
|
||||||
const messageExcerpt = (value: string): string =>
|
|
||||||
value.length > 36 ? `${value.slice(0, 36)}…` : value;
|
|
||||||
const messageTime = (value: string): string => {
|
const messageTime = (value: string): string => {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||||
@@ -198,9 +197,22 @@ export function FleetPage({
|
|||||||
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
|
[auth, capability, filter, page, query, selectedIds, snapshot, sort, tag, version],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fleetSummary = useMemo(() => {
|
||||||
|
const statuses = [...(snapshot?.statuses.values() ?? [])];
|
||||||
|
const online = statuses.filter(
|
||||||
|
(status) => status.reachable && status.authenticated !== false,
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
total: snapshot?.instances.length ?? 0,
|
||||||
|
online,
|
||||||
|
attention: (snapshot?.instances.length ?? 0) - online,
|
||||||
|
};
|
||||||
|
}, [snapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectAllRef.current)
|
if (selectAllRef.current) {
|
||||||
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
|
||||||
|
}
|
||||||
}, [model.visibleSelection.indeterminate]);
|
}, [model.visibleSelection.indeterminate]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page !== model.page) setPage(model.page);
|
if (page !== model.page) setPage(model.page);
|
||||||
@@ -332,6 +344,22 @@ export function FleetPage({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{snapshot ? (
|
||||||
|
<section className="fleet-status-summary" aria-label="实例状态摘要">
|
||||||
|
<div>
|
||||||
|
<span>实例总数</span>
|
||||||
|
<strong>{fleetSummary.total}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>在线</span>
|
||||||
|
<strong>{fleetSummary.online}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>需处理</span>
|
||||||
|
<strong>{fleetSummary.attention}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
{batchOpen ? (
|
{batchOpen ? (
|
||||||
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
<div className="batch-entry" role="region" aria-label="批量操作入口">
|
||||||
请为已选择的 {model.selectedIds.length} 项选择操作。
|
请为已选择的 {model.selectedIds.length} 项选择操作。
|
||||||
@@ -465,6 +493,7 @@ export function FleetPage({
|
|||||||
>
|
>
|
||||||
<h2>{row.displayName}</h2>
|
<h2>{row.displayName}</h2>
|
||||||
<code>{row.id}</code>
|
<code>{row.id}</code>
|
||||||
|
<span className="fleet-card-entry-label">进入仪表盘</span>
|
||||||
</a>
|
</a>
|
||||||
<span className={`status status-${row.statusKind}`}>
|
<span className={`status status-${row.statusKind}`}>
|
||||||
{STATUS_LABELS[row.statusKind]}
|
{STATUS_LABELS[row.statusKind]}
|
||||||
@@ -479,11 +508,39 @@ export function FleetPage({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>CPU</dt>
|
<dt>CPU</dt>
|
||||||
<dd>{percent(row.status?.summary?.resources?.cpuPercent)}</dd>
|
<dd>
|
||||||
|
{row.status?.summary?.resources?.cpuPercent === undefined ? (
|
||||||
|
'暂未获取'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<meter
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={row.status.summary.resources.cpuPercent}
|
||||||
|
aria-label="CPU 使用率"
|
||||||
|
/>
|
||||||
|
{percent(row.status.summary.resources.cpuPercent)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>内存</dt>
|
<dt>内存</dt>
|
||||||
<dd>{percent(row.status?.summary?.resources?.memoryPercent)}</dd>
|
<dd>
|
||||||
|
{row.status?.summary?.resources?.memoryPercent === undefined ? (
|
||||||
|
'暂未获取'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<meter
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={row.status.summary.resources.memoryPercent}
|
||||||
|
aria-label="内存使用率"
|
||||||
|
/>
|
||||||
|
{percent(row.status.summary.resources.memoryPercent)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>最高温度</dt>
|
<dt>最高温度</dt>
|
||||||
@@ -511,10 +568,6 @@ export function FleetPage({
|
|||||||
<dt>号码</dt>
|
<dt>号码</dt>
|
||||||
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
|
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>内容</dt>
|
|
||||||
<dd>{messageExcerpt(messageStates.get(row.id)!.latest!.content)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<dt>时间</dt>
|
<dt>时间</dt>
|
||||||
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
|
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
|
||||||
@@ -524,7 +577,11 @@ export function FleetPage({
|
|||||||
<p className="fleet-card-sms-empty">暂无短信</p>
|
<p className="fleet-card-sms-empty">暂无短信</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<div className="fleet-card-actions fleet-card-admin-actions">
|
<div
|
||||||
|
className="fleet-card-actions fleet-card-admin-actions"
|
||||||
|
role="group"
|
||||||
|
aria-label="实例管理操作"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||||
aria-label={`编辑 ${row.displayName}`}
|
aria-label={`编辑 ${row.displayName}`}
|
||||||
@@ -665,22 +722,7 @@ export function FleetPage({
|
|||||||
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||||
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
||||||
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||||
origin: (
|
origin: <td>{origin ? <span>{origin}</span> : <span>源地址无效</span>}</td>,
|
||||||
<td>
|
|
||||||
{origin ? (
|
|
||||||
<a
|
|
||||||
href={origin}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`打开 ${row.displayName} 的源站`}
|
|
||||||
>
|
|
||||||
{origin}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span>源地址无效</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} aria-selected={row.selected}>
|
<tr key={row.id} aria-selected={row.selected}>
|
||||||
|
|||||||
@@ -46,14 +46,20 @@ describe('InstanceDetail', () => {
|
|||||||
const links = navigation.getAllByRole('link');
|
const links = navigation.getAllByRole('link');
|
||||||
expect(links).toHaveLength(2);
|
expect(links).toHaveLength(2);
|
||||||
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
|
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
|
||||||
['实例仪表盘', '/instances/owner/overview'],
|
['仪表盘', '/instances/owner/overview'],
|
||||||
['短信管理', '/instances/owner/messages'],
|
['短信', '/instances/owner/messages'],
|
||||||
]);
|
]);
|
||||||
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
|
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
|
||||||
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
|
||||||
expect(screen.queryByText('能力状态未知。')).toBeNull();
|
expect(screen.queryByText('能力状态未知。')).toBeNull();
|
||||||
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
|
expect(screen.queryByText('此调制解调器不支持语音功能。')).toBeNull();
|
||||||
expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull();
|
expect(screen.queryByText('能力探测结果未包含 eSIM。')).toBeNull();
|
||||||
|
expect(screen.getByRole('link', { name: '返回实例总览' }).getAttribute('href')).toBe('/fleet');
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Owner modem' })).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
|
||||||
|
expect(screen.getByText('已认证')).toBeTruthy();
|
||||||
|
expect(screen.getByText('数据最新')).toBeTruthy();
|
||||||
|
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the business titles for both primary pages', () => {
|
it('uses the business titles for both primary pages', () => {
|
||||||
@@ -106,7 +112,7 @@ describe('InstanceDetail', () => {
|
|||||||
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
|
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('状态')).toBeTruthy();
|
expect(screen.getByText('状态')).toBeTruthy();
|
||||||
expect(screen.getByText('在线')).toBeTruthy();
|
expect(screen.getAllByText('在线')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never renders or loads context when the direct-route owner does not match', () => {
|
it('never renders or loads context when the direct-route owner does not match', () => {
|
||||||
@@ -159,7 +165,7 @@ describe('InstanceDetail', () => {
|
|||||||
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
expect(pending.get('owner')?.signal.aborted).toBe(true);
|
||||||
|
|
||||||
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
pending.get('second')?.resolve({ overview: { state: 'supported' } });
|
||||||
expect(await instanceNavigation().findByRole('link', { name: '实例仪表盘' })).toBeTruthy();
|
expect(await instanceNavigation().findByRole('link', { name: '仪表盘' })).toBeTruthy();
|
||||||
pending
|
pending
|
||||||
.get('owner')
|
.get('owner')
|
||||||
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
|
const PRIMARY_MODULES = ['overview', 'messages'] as const satisfies readonly InstanceModule[];
|
||||||
|
const PRIMARY_LABELS: Readonly<Record<(typeof PRIMARY_MODULES)[number], string>> = {
|
||||||
|
overview: '仪表盘',
|
||||||
|
messages: '短信',
|
||||||
|
};
|
||||||
|
const AUTH_LABELS = { authenticated: '已认证', 'auth-required': '需要认证' } as const;
|
||||||
|
const FRESHNESS_LABELS = {
|
||||||
|
fresh: '数据最新',
|
||||||
|
stale: '数据可能过期',
|
||||||
|
expired: '数据已过期',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
||||||
degraded: '此模块可用,但功能受限。',
|
degraded: '此模块可用,但功能受限。',
|
||||||
@@ -126,23 +136,37 @@ export function InstanceDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="instance-detail">
|
<section className="instance-detail">
|
||||||
<aside className="instance-context" aria-label="当前实例">
|
<header className="instance-context" aria-label="当前实例">
|
||||||
<strong>{instance.name}</strong>
|
<a className="instance-breadcrumb" href="/fleet">
|
||||||
<code>{instance.id}</code>
|
返回实例总览
|
||||||
|
</a>
|
||||||
|
<div className="instance-identity">
|
||||||
|
<div>
|
||||||
|
<h1>{instance.name}</h1>
|
||||||
|
<code>{instance.id}</code>
|
||||||
|
</div>
|
||||||
|
<div className="instance-context-badges">
|
||||||
|
{instance.status !== 'unknown' ? <span>{displayStatus(instance.status)}</span> : null}
|
||||||
|
{instance.authentication !== 'unknown' ? (
|
||||||
|
<span>{AUTH_LABELS[instance.authentication]}</span>
|
||||||
|
) : null}
|
||||||
|
{instance.freshness !== 'unknown' ? (
|
||||||
|
<span>{FRESHNESS_LABELS[instance.freshness]}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{instance.status !== 'unknown' ? (
|
{instance.status !== 'unknown' ? (
|
||||||
<dl>
|
<dl className="visually-hidden">
|
||||||
<div>
|
<div>
|
||||||
<dt>状态</dt>
|
<dt>状态</dt>
|
||||||
<dd>{displayStatus(instance.status)}</dd>
|
<dd>{displayStatus(instance.status)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
) : null}
|
) : null}
|
||||||
{origin ? (
|
<div className="instance-header-actions">
|
||||||
<a href={origin} target="_blank" rel="noopener noreferrer">
|
{origin ? <span title={origin}>源站已由控制平面托管</span> : null}
|
||||||
打开源站
|
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
||||||
</a>
|
</div>
|
||||||
) : null}
|
|
||||||
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
|
||||||
<nav aria-label="实例模块">
|
<nav aria-label="实例模块">
|
||||||
<ul>
|
<ul>
|
||||||
{PRIMARY_MODULES.map((item) => {
|
{PRIMARY_MODULES.map((item) => {
|
||||||
@@ -152,16 +176,16 @@ export function InstanceDetail({
|
|||||||
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
||||||
aria-current={module === item ? 'page' : undefined}
|
aria-current={module === item ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{INSTANCE_MODULE_LABELS[item]}
|
{PRIMARY_LABELS[item]}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</header>
|
||||||
<div className="instance-module-detail">
|
<div className="instance-module-detail">
|
||||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
<h2>{INSTANCE_MODULE_LABELS[module]}</h2>
|
||||||
{loading ? <p role="status">正在加载能力…</p> : null}
|
{loading ? <p role="status">正在加载能力…</p> : null}
|
||||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||||
{!loading && canOpen(module, activeCapability) ? (
|
{!loading && canOpen(module, activeCapability) ? (
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ const owner: InstanceContext = {
|
|||||||
status: 'online',
|
status: 'online',
|
||||||
authentication: 'authenticated',
|
authentication: 'authenticated',
|
||||||
freshness: 'fresh',
|
freshness: 'fresh',
|
||||||
|
resources: {
|
||||||
|
cpuPercent: 18.4,
|
||||||
|
memoryPercent: 63.2,
|
||||||
|
maxTemperatureCelsius: 46.7,
|
||||||
|
phoneNumbers: ['13800138000'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const overviewCapabilities: InstanceCapabilityMap = {
|
const overviewCapabilities: InstanceCapabilityMap = {
|
||||||
overview: { state: 'supported' },
|
overview: { state: 'supported' },
|
||||||
@@ -108,7 +114,7 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is honest when no safe read source is injected and never attempts a production endpoint', () => {
|
it('renders the route-owned Fleet resource summary without inventing another endpoint', () => {
|
||||||
render(
|
render(
|
||||||
<AppShell
|
<AppShell
|
||||||
pathname="/instances/alpha/overview"
|
pathname="/instances/alpha/overview"
|
||||||
@@ -116,9 +122,17 @@ describe('Phase 6.1 Overview / System read slice', () => {
|
|||||||
capabilities={overviewCapabilities}
|
capabilities={overviewCapabilities}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole('status', { name: '概览不可用' }).textContent).toMatch(
|
const operations = screen.getByRole('region', { name: '运行状态与资源' });
|
||||||
/没有可用的安全概览只读数据源/i,
|
expect(within(operations).getByText('在线')).toBeTruthy();
|
||||||
|
expect(within(operations).getByRole('meter', { name: 'CPU使用率' }).getAttribute('value')).toBe(
|
||||||
|
'18.4',
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
within(operations).getByRole('meter', { name: '内存使用率' }).getAttribute('value'),
|
||||||
|
).toBe('63.2');
|
||||||
|
expect(within(operations).getByText('46.7 °C')).toBeTruthy();
|
||||||
|
expect(within(operations).getByText('13800138000')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/没有可用的安全概览只读数据源/i)).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /restart/i })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,66 @@ function StructuredSection({ label, values }: { label: string; values: OverviewS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResourceSummary({ instance }: { instance: InstanceContext }) {
|
||||||
|
const resources = instance.resources;
|
||||||
|
const metric = (label: string, value: number | undefined) => (
|
||||||
|
<article className="overview-resource-card">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value === undefined ? (
|
||||||
|
<strong>暂未获取</strong>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>{value.toFixed(1)}%</strong>
|
||||||
|
<meter min="0" max="100" value={value} aria-label={`${label}使用率`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="overview-operations" aria-label="运行状态与资源">
|
||||||
|
<h2>运行状态</h2>
|
||||||
|
{instance.resourceSummaryState === 'loading' ? (
|
||||||
|
<p role="status" aria-label="资源摘要状态">
|
||||||
|
正在加载资源摘要…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{instance.resourceSummaryState === 'unavailable' ? (
|
||||||
|
<p role="status" aria-label="资源摘要状态">
|
||||||
|
资源摘要暂不可用,实例基本信息仍可使用。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="overview-resource-grid">
|
||||||
|
<article className="overview-resource-card">
|
||||||
|
<span>连接状态</span>
|
||||||
|
<strong>
|
||||||
|
{instance.status === 'online'
|
||||||
|
? '在线'
|
||||||
|
: instance.status === 'offline'
|
||||||
|
? '离线'
|
||||||
|
: instance.status === 'auth-required'
|
||||||
|
? '需要认证'
|
||||||
|
: '未知'}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
{metric('CPU', resources?.cpuPercent)}
|
||||||
|
{metric('内存', resources?.memoryPercent)}
|
||||||
|
<article className="overview-resource-card">
|
||||||
|
<span>最高温度</span>
|
||||||
|
<strong>
|
||||||
|
{resources?.maxTemperatureCelsius === undefined
|
||||||
|
? '暂未获取'
|
||||||
|
: `${resources.maxTemperatureCelsius.toFixed(1)} °C`}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<article className="overview-resource-card overview-resource-phone">
|
||||||
|
<span>手机号</span>
|
||||||
|
<strong>{resources?.phoneNumbers?.join('、') || '暂未获取'}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OverviewSystemPage({
|
export function OverviewSystemPage({
|
||||||
instance,
|
instance,
|
||||||
dataSource,
|
dataSource,
|
||||||
@@ -159,12 +219,7 @@ export function OverviewSystemPage({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!dataSource)
|
if (!dataSource) return <ResourceSummary instance={instance} />;
|
||||||
return (
|
|
||||||
<div className="state-panel" role="status" aria-label="概览不可用">
|
|
||||||
没有可用的安全概览只读数据源。此控制台不会臆造或调用未签订契约的生产端点。
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const retainedSnapshot =
|
const retainedSnapshot =
|
||||||
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
|
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
|
||||||
@@ -191,6 +246,7 @@ export function OverviewSystemPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overview-system">
|
<div className="overview-system">
|
||||||
|
<ResourceSummary instance={instance} />
|
||||||
{state.kind === 'loading' ? <p role="status">正在刷新概览…</p> : null}
|
{state.kind === 'loading' ? <p role="status">正在刷新概览…</p> : null}
|
||||||
{state.kind === 'error' ? (
|
{state.kind === 'error' ? (
|
||||||
<div className="state-panel state-error" role="alert">
|
<div className="state-panel state-error" role="alert">
|
||||||
@@ -211,10 +267,10 @@ export function OverviewSystemPage({
|
|||||||
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
<StructuredSection key={key} label={label} values={snapshot[key]} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<section className="state-panel" aria-label="系统操作">
|
<details className="system-operation-note">
|
||||||
<h2>系统操作</h2>
|
<summary>系统操作说明</summary>
|
||||||
<p>重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。</p>
|
<p>重启不可用,因为后端尚未实现 R3 重启操作,因此不提供任何操作。</p>
|
||||||
</section>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-5
@@ -283,6 +283,33 @@ small {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.fleet-status-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.fleet-status-summary div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
.fleet-card-entry-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #187f77;
|
||||||
|
}
|
||||||
|
.fleet-card meter {
|
||||||
|
width: min(7rem, 45%);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
accent-color: var(--mint-dark);
|
||||||
|
}
|
||||||
.fleet-card {
|
.fleet-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -385,6 +412,8 @@ small {
|
|||||||
border-color: #db938d;
|
border-color: #db938d;
|
||||||
}
|
}
|
||||||
.card-delete-confirmation {
|
.card-delete-confirmation {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -628,14 +657,76 @@ tbody th small {
|
|||||||
}
|
}
|
||||||
.instance-detail {
|
.instance-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 13rem minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.instance-context {
|
.instance-context {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 1rem;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem 1.25rem;
|
||||||
|
padding: 1.15rem 1.25rem 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: var(--surface);
|
background: var(--surface-raised);
|
||||||
|
box-shadow: 0 8px 24px rgba(121, 79, 39, 0.08);
|
||||||
|
}
|
||||||
|
.instance-breadcrumb {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.instance-identity {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.instance-identity h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.instance-context-badges,
|
||||||
|
.instance-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.instance-context-badges span {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #187f77;
|
||||||
|
background: #d8f5ec;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.instance-header-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.instance-header-actions span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.instance-context nav {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.instance-context nav ul {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.instance-context nav a {
|
||||||
|
border-radius: 0;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.instance-context nav a[aria-current='page'] {
|
||||||
|
border-bottom-color: var(--mint-dark);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.instance-context code {
|
.instance-context code {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -836,6 +927,47 @@ dd {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.overview-operations {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.overview-operations h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.overview-resource-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.overview-resource-card {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
.overview-resource-card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.overview-resource-card strong {
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.overview-resource-card meter {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--mint-dark);
|
||||||
|
}
|
||||||
|
.system-operation-note {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.overview-card,
|
.overview-card,
|
||||||
.cellular-card,
|
.cellular-card,
|
||||||
.device-network-card,
|
.device-network-card,
|
||||||
@@ -917,8 +1049,17 @@ main ul[aria-label='已配置实例'] {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.instance-context {
|
.instance-context {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
.instance-breadcrumb,
|
||||||
|
.instance-context nav {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.instance-header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
.messages-workbench {
|
.messages-workbench {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -931,8 +1072,8 @@ main ul[aria-label='已配置实例'] {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
.instance-context ul {
|
.instance-context ul {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
flex-wrap: wrap;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
.instance-context li {
|
.instance-context li {
|
||||||
@@ -974,6 +1115,9 @@ main ul[aria-label='已配置实例'] {
|
|||||||
.fleet-card-section {
|
.fleet-card-section {
|
||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
}
|
}
|
||||||
|
.fleet-status-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user