feat(web): redesign fleet cards and instance workspace

This commit is contained in:
chick
2026-07-19 18:54:26 +08:00
parent f2803896a8
commit 49ee4e6570
9 changed files with 458 additions and 74 deletions
+36 -8
View File
@@ -87,10 +87,8 @@ describe('React AppShell and Fleet vertical slice', () => {
'/instances/alpha/overview',
);
expect(
within(screen.getByRole('row', { name: /Bravo/ }))
.getByRole('link', { name: '打开 Bravo 的源站' })
.getAttribute('href'),
).toBe('http://bravo.example:8080');
within(screen.getByRole('row', { name: /Bravo/ })).getByText('http://bravo.example:8080'),
).toBeTruthy();
expect(screen.getByText('版本 0.1.0')).toBeTruthy();
expect(
consoleError.mock.calls.some((call) =>
@@ -159,7 +157,7 @@ describe('React AppShell and Fleet vertical slice', () => {
expect(within(card).getByText('13800138000')).toBeTruthy();
expect(await within(card).findByText('收到')).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).queryByText('能力未知')).toBeNull();
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 () => {
const user = userEvent.setup();
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.getByText('Owner modem')).toBeTruthy();
expect(screen.getByRole('link', { name: '打开源站' }).getAttribute('href')).toBe(
'https://owner.example',
);
expect(screen.getByText('源站已由控制平面托管')).toBeTruthy();
expect(screen.queryByRole('link', { name: '打开源站' })).toBeNull();
rerender(<AppShell pathname="/instances/someone-else/messages" instance={instance} />);
expect(screen.queryByText('Owner modem')).toBeNull();
+62 -1
View File
@@ -78,6 +78,13 @@ export interface InstanceContext {
status: 'online' | 'offline' | 'auth-required' | 'degraded' | 'unknown';
authentication: 'authenticated' | 'auth-required' | 'unknown';
freshness: 'fresh' | 'stale' | 'expired' | 'unknown';
resources?: Readonly<{
cpuPercent?: number;
memoryPercent?: number;
maxTemperatureCelsius?: number;
phoneNumbers?: readonly string[];
}>;
resourceSummaryState?: 'loading' | 'ready' | 'unavailable';
}
export interface AppShellProps {
pathname: string;
@@ -440,8 +447,13 @@ export function AppShell({
setInstanceLoading(false);
return;
}
const controller = new AbortController();
let active = 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(
(owner) => {
if (!active) return;
@@ -457,8 +469,50 @@ export function AppShell({
status: 'unknown',
authentication: 'unknown',
freshness: 'unknown',
resourceSummaryState: 'loading',
});
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;
@@ -468,8 +522,15 @@ export function AppShell({
);
return () => {
active = false;
controller.abort();
};
}, [instance, resolvedInstanceDataSource, routeInstanceId]);
}, [
defaultFleetDataSource,
fleetDataSource,
instance,
resolvedInstanceDataSource,
routeInstanceId,
]);
const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance;
const refresh = useControlPlaneEvents(
+14 -5
View File
@@ -18,11 +18,9 @@ const snapshot: FleetSnapshot = {
'alpha',
{
instanceId: 'alpha',
connectivity: 'online',
authentication: 'authenticated',
freshness: 'fresh',
capabilities: ['overview', 'messages'],
anomalies: [],
reachable: true,
authenticated: true,
summary: { freshness: 'fresh', resources: { cpuPercent: 24, memoryPercent: 51 } },
},
],
]),
@@ -41,5 +39,16 @@ describe('FleetPage card navigation', () => {
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
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();
});
});
+68 -26
View File
@@ -82,8 +82,7 @@ const messageDirection = (value: string): string =>
: value === 'outgoing' || value === 'sent'
? '发送'
: '未知方向';
const messageExcerpt = (value: string): string =>
value.length > 36 ? `${value.slice(0, 36)}` : value;
const messageTime = (value: string): string => {
const date = new Date(value);
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],
);
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(() => {
if (selectAllRef.current)
if (selectAllRef.current) {
selectAllRef.current.indeterminate = model.visibleSelection.indeterminate;
}
}, [model.visibleSelection.indeterminate]);
useEffect(() => {
if (page !== model.page) setPage(model.page);
@@ -332,6 +344,22 @@ export function FleetPage({
</button>
</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 ? (
<div className="batch-entry" role="region" aria-label="批量操作入口">
{model.selectedIds.length}
@@ -465,6 +493,7 @@ export function FleetPage({
>
<h2>{row.displayName}</h2>
<code>{row.id}</code>
<span className="fleet-card-entry-label"></span>
</a>
<span className={`status status-${row.statusKind}`}>
{STATUS_LABELS[row.statusKind]}
@@ -479,11 +508,39 @@ export function FleetPage({
</div>
<div>
<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>
<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>
<dt></dt>
@@ -511,10 +568,6 @@ export function FleetPage({
<dt></dt>
<dd>{messageStates.get(row.id)!.latest!.phoneNumber}</dd>
</div>
<div>
<dt></dt>
<dd>{messageExcerpt(messageStates.get(row.id)!.latest!.content)}</dd>
</div>
<div>
<dt></dt>
<dd>{messageTime(messageStates.get(row.id)!.latest!.timestamp)}</dd>
@@ -524,7 +577,11 @@ export function FleetPage({
<p className="fleet-card-sms-empty"></p>
)}
</section>
<div className="fleet-card-actions fleet-card-admin-actions">
<div
className="fleet-card-actions fleet-card-admin-actions"
role="group"
aria-label="实例管理操作"
>
<a
href={`/settings/instances/${encodeURIComponent(row.id)}`}
aria-label={`编辑 ${row.displayName}`}
@@ -665,22 +722,7 @@ export function FleetPage({
tags: <td>{row.tags.join(', ') || '—'}</td>,
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
origin: (
<td>
{origin ? (
<a
href={origin}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开 ${row.displayName} 的源站`}
>
{origin}
</a>
) : (
<span></span>
)}
</td>
),
origin: <td>{origin ? <span>{origin}</span> : <span></span>}</td>,
};
return (
<tr key={row.id} aria-selected={row.selected}>
@@ -46,14 +46,20 @@ describe('InstanceDetail', () => {
const links = navigation.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links.map((link) => [link.textContent, link.getAttribute('href')])).toEqual([
['实例仪表盘', '/instances/owner/overview'],
['短信管理', '/instances/owner/messages'],
['仪表盘', '/instances/owner/overview'],
['短信', '/instances/owner/messages'],
]);
expect(navigation.queryByText(/蜂窝网络|设备网络|通话|eSIM|通知|自动化|OTA/)).toBeNull();
expect(screen.getByRole('heading', { name: '实例仪表盘' })).toBeTruthy();
expect(screen.queryByText('能力状态未知。')).toBeNull();
expect(screen.queryByText('此调制解调器不支持语音功能。')).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', () => {
@@ -106,7 +112,7 @@ describe('InstanceDetail', () => {
<InstanceDetail instanceId="owner" module="overview" instance={owner} capabilities={{}} />,
);
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', () => {
@@ -159,7 +165,7 @@ describe('InstanceDetail', () => {
expect(pending.get('owner')?.signal.aborted).toBe(true);
pending.get('second')?.resolve({ overview: { state: 'supported' } });
expect(await instanceNavigation().findByRole('link', { name: '实例仪表盘' })).toBeTruthy();
expect(await instanceNavigation().findByRole('link', { name: '仪表盘' })).toBeTruthy();
pending
.get('owner')
?.resolve({ overview: { state: 'unsupported', explanation: 'Stale owner result' } });
+37 -13
View File
@@ -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_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>> = {
degraded: '此模块可用,但功能受限。',
@@ -126,23 +136,37 @@ export function InstanceDetail({
return (
<section className="instance-detail">
<aside className="instance-context" aria-label="当前实例">
<strong>{instance.name}</strong>
<code>{instance.id}</code>
<header className="instance-context" aria-label="当前实例">
<a className="instance-breadcrumb" href="/fleet">
</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' ? (
<dl>
<dl className="visually-hidden">
<div>
<dt></dt>
<dd>{displayStatus(instance.status)}</dd>
</div>
</dl>
) : null}
{origin ? (
<a href={origin} target="_blank" rel="noopener noreferrer">
</a>
) : null}
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}></a>
<div className="instance-header-actions">
{origin ? <span title={origin}></span> : null}
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}></a>
</div>
<nav aria-label="实例模块">
<ul>
{PRIMARY_MODULES.map((item) => {
@@ -152,16 +176,16 @@ export function InstanceDetail({
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
aria-current={module === item ? 'page' : undefined}
>
{INSTANCE_MODULE_LABELS[item]}
{PRIMARY_LABELS[item]}
</a>
</li>
);
})}
</ul>
</nav>
</aside>
</header>
<div className="instance-module-detail">
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
<h2>{INSTANCE_MODULE_LABELS[module]}</h2>
{loading ? <p role="status"></p> : null}
{loadError ? <p role="alert">{loadError}</p> : null}
{!loading && canOpen(module, activeCapability) ? (
@@ -20,6 +20,12 @@ const owner: InstanceContext = {
status: 'online',
authentication: 'authenticated',
freshness: 'fresh',
resources: {
cpuPercent: 18.4,
memoryPercent: 63.2,
maxTemperatureCelsius: 46.7,
phoneNumbers: ['13800138000'],
},
};
const overviewCapabilities: InstanceCapabilityMap = {
overview: { state: 'supported' },
@@ -108,7 +114,7 @@ describe('Phase 6.1 Overview / System read slice', () => {
).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(
<AppShell
pathname="/instances/alpha/overview"
@@ -116,9 +122,17 @@ describe('Phase 6.1 Overview / System read slice', () => {
capabilities={overviewCapabilities}
/>,
);
expect(screen.getByRole('status', { name: '概览不可用' }).textContent).toMatch(
/没有可用的安全概览只读数据源/i,
const operations = screen.getByRole('region', { name: '运行状态与资源' });
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();
});
+65 -9
View File
@@ -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({
instance,
dataSource,
@@ -159,12 +219,7 @@ export function OverviewSystemPage({
</div>
);
if (!dataSource)
return (
<div className="state-panel" role="status" aria-label="概览不可用">
</div>
);
if (!dataSource) return <ResourceSummary instance={instance} />;
const retainedSnapshot =
state.kind === 'loading' || state.kind === 'error' ? state.snapshot : undefined;
@@ -191,6 +246,7 @@ export function OverviewSystemPage({
return (
<div className="overview-system">
<ResourceSummary instance={instance} />
{state.kind === 'loading' ? <p role="status"></p> : null}
{state.kind === 'error' ? (
<div className="state-panel state-error" role="alert">
@@ -211,10 +267,10 @@ export function OverviewSystemPage({
<StructuredSection key={key} label={label} values={snapshot[key]} />
))}
</div>
<section className="state-panel" aria-label="系统操作">
<h2></h2>
<details className="system-operation-note">
<summary></summary>
<p> R3 </p>
</section>
</details>
</div>
);
}
+149 -5
View File
@@ -283,6 +283,33 @@ small {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
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 {
position: relative;
min-width: 0;
@@ -385,6 +412,8 @@ small {
border-color: #db938d;
}
.card-delete-confirmation {
position: relative;
z-index: 1;
display: grid;
gap: 0.65rem;
margin-top: 1rem;
@@ -628,14 +657,76 @@ tbody th small {
}
.instance-detail {
display: grid;
grid-template-columns: 13rem minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
}
.instance-context {
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;
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 {
display: block;
@@ -836,6 +927,47 @@ dd {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
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,
.cellular-card,
.device-network-card,
@@ -917,8 +1049,17 @@ main ul[aria-label='已配置实例'] {
display: block;
}
.instance-context {
display: grid;
grid-template-columns: minmax(0, 1fr);
margin-bottom: 1rem;
}
.instance-breadcrumb,
.instance-context nav {
grid-column: 1;
}
.instance-header-actions {
justify-content: flex-start;
}
.messages-workbench {
grid-template-columns: minmax(0, 1fr);
min-height: 0;
@@ -931,8 +1072,8 @@ main ul[aria-label='已配置实例'] {
max-height: none;
}
.instance-context ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
display: flex;
flex-wrap: wrap;
overflow: visible;
}
.instance-context li {
@@ -974,6 +1115,9 @@ main ul[aria-label='已配置实例'] {
.fleet-card-section {
margin-top: 0.55rem;
}
.fleet-status-summary {
grid-template-columns: minmax(0, 1fr);
}
.table-scroll {
overflow-x: auto;
border: 1px solid var(--border);