feat(web): make instance overview card-first
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { AppShell, type InstanceContext } from './app-shell.js';
|
|||||||
import type { AuditDataSource } from './audit/audit-page.js';
|
import type { AuditDataSource } from './audit/audit-page.js';
|
||||||
import type { EventStreamClient } from './events/event-stream-client.js';
|
import type { EventStreamClient } from './events/event-stream-client.js';
|
||||||
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
import { type FleetDataSource, type FleetSnapshot } from './fleet/fleet-page.js';
|
||||||
|
import type { InstanceDataSource } from './instances/instance-api-data-source.js';
|
||||||
import type { JobsDataSource } from './jobs/jobs-page.js';
|
import type { JobsDataSource } from './jobs/jobs-page.js';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -91,6 +92,82 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes cards, add, detail, edit, and safe deletion available from the overview', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const remove = vi.fn<InstanceDataSource['delete']>().mockResolvedValue(undefined);
|
||||||
|
const instanceDataSource: InstanceDataSource = {
|
||||||
|
get: vi.fn().mockResolvedValue({
|
||||||
|
id: 'bravo',
|
||||||
|
name: 'Bravo',
|
||||||
|
origin: 'http://bravo.example:8080',
|
||||||
|
tags: ['west'],
|
||||||
|
revision: 7,
|
||||||
|
credentialConfigured: true,
|
||||||
|
}),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
delete: remove,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppShell
|
||||||
|
pathname="/fleet"
|
||||||
|
fleetDataSource={source(async () => snapshot)}
|
||||||
|
instanceDataSource={instanceDataSource}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('region', { name: '实例卡片' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: '添加实例' }).getAttribute('href')).toBe(
|
||||||
|
'/instances/new',
|
||||||
|
);
|
||||||
|
const card = screen.getByRole('article', { name: 'Bravo 实例概览' });
|
||||||
|
expect(within(card).getByText('40 ms')).toBeTruthy();
|
||||||
|
expect(within(card).getByText('2.0')).toBeTruthy();
|
||||||
|
expect(within(card).getByRole('link', { name: '管理 Bravo' }).getAttribute('href')).toBe(
|
||||||
|
'/instances/bravo/overview',
|
||||||
|
);
|
||||||
|
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe(
|
||||||
|
'/settings/instances/bravo',
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(within(card).getByRole('button', { name: '删除 Bravo' }));
|
||||||
|
expect(within(card).getByText('删除后无法撤销。')).toBeTruthy();
|
||||||
|
await user.type(within(card).getByRole('textbox', { name: '输入 bravo 以确认删除' }), 'bravo');
|
||||||
|
await user.click(within(card).getByRole('button', { name: '确认删除 Bravo' }));
|
||||||
|
expect(remove).toHaveBeenCalledWith('bravo', 7);
|
||||||
|
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads route-owned instance context so overview-card navigation opens detail management', async () => {
|
||||||
|
const instanceDataSource: InstanceDataSource = {
|
||||||
|
get: vi.fn().mockResolvedValue({
|
||||||
|
id: 'bravo',
|
||||||
|
name: 'Bravo',
|
||||||
|
origin: 'http://bravo.example:8080',
|
||||||
|
tags: ['west'],
|
||||||
|
revision: 7,
|
||||||
|
credentialConfigured: true,
|
||||||
|
}),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppShell pathname="/instances/bravo/overview" instanceDataSource={instanceDataSource} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
||||||
|
expect(await screen.findByText('Bravo')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('heading', { name: '概览' })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
|
||||||
|
'/settings/instances/bravo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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)} />);
|
||||||
@@ -164,7 +241,6 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('paginates fleet rows and selects only the current page', async () => {
|
it('paginates fleet rows and selects only the current page', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const many: FleetSnapshot = {
|
const many: FleetSnapshot = {
|
||||||
instances: Array.from({ length: 11 }, (_, index) => ({
|
instances: Array.from({ length: 11 }, (_, index) => ({
|
||||||
id: `instance-${String(index + 1).padStart(2, '0')}`,
|
id: `instance-${String(index + 1).padStart(2, '0')}`,
|
||||||
@@ -178,9 +254,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
|
expect(screen.queryByRole('row', { name: /Instance 11/ })).toBeNull();
|
||||||
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
|
expect(screen.getByText('第 1 页,共 2 页')).toBeTruthy();
|
||||||
await user.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
|
fireEvent.click(screen.getByRole('checkbox', { name: '选择当前页全部实例' }));
|
||||||
expect(screen.getByText('已选择 10 项')).toBeTruthy();
|
expect(screen.getByText('已选择 10 项')).toBeTruthy();
|
||||||
await user.click(screen.getByRole('button', { name: '下一页' }));
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||||
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy();
|
expect(await screen.findByRole('row', { name: /Instance 11/ })).toBeTruthy();
|
||||||
expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy();
|
expect(screen.getByText('第 2 页,共 2 页')).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -205,7 +281,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
|
|
||||||
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();
|
||||||
expect(screen.getByText(/此路由缺少实例上下文/i)).toBeTruthy();
|
expect(screen.getByRole('status').textContent).toContain('正在加载实例');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
+78
-23
@@ -1,7 +1,7 @@
|
|||||||
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
|
||||||
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useControlPlaneEvents } from './events/use-control-plane-events.js';
|
import { useControlPlaneEvents } from './events/use-control-plane-events.js';
|
||||||
import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js';
|
import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js';
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { EsimModule, type EsimDataSource } from './instances/esim-module.js';
|
import { EsimModule, type EsimDataSource } from './instances/esim-module.js';
|
||||||
import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js';
|
import { OverviewSystemPage, type OverviewDataSource } from './instances/overview-system.js';
|
||||||
import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js';
|
import { InstanceEditor, type InstanceDataSource } from './instances/instance-crud.js';
|
||||||
|
import { createInstanceApiDataSource } from './instances/instance-api-data-source.js';
|
||||||
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
|
import { JobsPage, type JobsDataSource } from './jobs/jobs-page.js';
|
||||||
import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
|
import { createJobsApiDataSource } from './jobs/jobs-api-data-source.js';
|
||||||
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
|
import { MessagesModule, type MessagesDataSource } from './instances/messages-module.js';
|
||||||
@@ -198,6 +199,7 @@ function Page({
|
|||||||
return (
|
return (
|
||||||
<FleetPage
|
<FleetPage
|
||||||
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
||||||
|
{...(instanceDataSource ? { instanceDataSource } : {})}
|
||||||
{...(fleetData ? { initialData: fleetData } : {})}
|
{...(fleetData ? { initialData: fleetData } : {})}
|
||||||
refreshSignal={fleetRefreshSignal}
|
refreshSignal={fleetRefreshSignal}
|
||||||
/>
|
/>
|
||||||
@@ -407,6 +409,7 @@ export function AppShell({
|
|||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
|
||||||
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
const defaultFleetDataSource = useMemo(() => createFleetApiDataSource(), []);
|
||||||
|
const defaultInstanceDataSource = useMemo(() => createInstanceApiDataSource(), []);
|
||||||
const resolvedJobsDataSource = useMemo(
|
const resolvedJobsDataSource = useMemo(
|
||||||
() => jobsDataSource ?? createJobsApiDataSource(),
|
() => jobsDataSource ?? createJobsApiDataSource(),
|
||||||
[jobsDataSource],
|
[jobsDataSource],
|
||||||
@@ -418,6 +421,50 @@ export function AppShell({
|
|||||||
const resolved = resolveRoute(pathname);
|
const resolved = resolveRoute(pathname);
|
||||||
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
|
||||||
const routeInstanceId = route.params?.instanceId;
|
const routeInstanceId = route.params?.instanceId;
|
||||||
|
const resolvedInstanceDataSource = instanceDataSource ?? defaultInstanceDataSource;
|
||||||
|
const [loadedInstance, setLoadedInstance] = useState<InstanceContext>();
|
||||||
|
const [instanceLoading, setInstanceLoading] = useState(false);
|
||||||
|
const [instanceLoadFailed, setInstanceLoadFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadedInstance(undefined);
|
||||||
|
setInstanceLoadFailed(false);
|
||||||
|
if (!routeInstanceId || instance?.id === routeInstanceId) {
|
||||||
|
setInstanceLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
setInstanceLoading(true);
|
||||||
|
void resolvedInstanceDataSource.get(routeInstanceId).then(
|
||||||
|
(owner) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (owner.id !== routeInstanceId) {
|
||||||
|
setInstanceLoadFailed(true);
|
||||||
|
setInstanceLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadedInstance({
|
||||||
|
id: owner.id,
|
||||||
|
name: owner.name,
|
||||||
|
origin: owner.origin,
|
||||||
|
status: 'unknown',
|
||||||
|
authentication: 'unknown',
|
||||||
|
freshness: 'unknown',
|
||||||
|
});
|
||||||
|
setInstanceLoading(false);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!active) return;
|
||||||
|
setInstanceLoadFailed(true);
|
||||||
|
setInstanceLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [instance, resolvedInstanceDataSource, routeInstanceId]);
|
||||||
|
|
||||||
|
const routeInstance = instance?.id === routeInstanceId ? instance : loadedInstance;
|
||||||
const refresh = useControlPlaneEvents(
|
const refresh = useControlPlaneEvents(
|
||||||
routeInstanceId,
|
routeInstanceId,
|
||||||
eventStreamClient ?? defaultEventStreamClient,
|
eventStreamClient ?? defaultEventStreamClient,
|
||||||
@@ -455,28 +502,36 @@ export function AppShell({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<main id="main-content" tabIndex={-1}>
|
<main id="main-content" tabIndex={-1}>
|
||||||
<Page
|
{routeInstanceId && instanceLoading ? (
|
||||||
route={route}
|
<p role="status">正在加载实例…</p>
|
||||||
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
) : routeInstanceId && instanceLoadFailed ? (
|
||||||
fleetData={fleetData}
|
<p role="alert" className="state-panel state-error">
|
||||||
instance={instance}
|
无法加载此实例,请返回总览后重试。
|
||||||
instanceDataSource={instanceDataSource}
|
</p>
|
||||||
capabilities={capabilities}
|
) : (
|
||||||
capabilityDataSource={capabilityDataSource}
|
<Page
|
||||||
overviewDataSource={overviewDataSource}
|
route={route}
|
||||||
cellularDataSource={cellularDataSource}
|
fleetDataSource={fleetDataSource ?? defaultFleetDataSource}
|
||||||
deviceNetworkDataSource={deviceNetworkDataSource}
|
fleetData={fleetData}
|
||||||
messagesDataSource={messagesDataSource}
|
instance={routeInstance}
|
||||||
callsDataSource={callsDataSource}
|
instanceDataSource={resolvedInstanceDataSource}
|
||||||
esimDataSource={esimDataSource}
|
capabilities={capabilities}
|
||||||
notificationsDataSource={notificationsDataSource}
|
capabilityDataSource={capabilityDataSource}
|
||||||
automationDataSource={automationDataSource}
|
overviewDataSource={overviewDataSource}
|
||||||
otaDataSource={otaDataSource}
|
cellularDataSource={cellularDataSource}
|
||||||
jobsDataSource={resolvedJobsDataSource}
|
deviceNetworkDataSource={deviceNetworkDataSource}
|
||||||
auditDataSource={resolvedAuditDataSource}
|
messagesDataSource={messagesDataSource}
|
||||||
fleetRefreshSignal={refresh.fleet}
|
callsDataSource={callsDataSource}
|
||||||
detailRefreshSignal={refresh.detail}
|
esimDataSource={esimDataSource}
|
||||||
/>
|
notificationsDataSource={notificationsDataSource}
|
||||||
|
automationDataSource={automationDataSource}
|
||||||
|
otaDataSource={otaDataSource}
|
||||||
|
jobsDataSource={resolvedJobsDataSource}
|
||||||
|
auditDataSource={resolvedAuditDataSource}
|
||||||
|
fleetRefreshSignal={refresh.fleet}
|
||||||
|
detailRefreshSignal={refresh.detail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+385
-195
@@ -9,6 +9,7 @@ import {
|
|||||||
type FleetStatus,
|
type FleetStatus,
|
||||||
type SortDirection,
|
type SortDirection,
|
||||||
} from './fleet-table-view-model.js';
|
} from './fleet-table-view-model.js';
|
||||||
|
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
||||||
|
|
||||||
export interface FleetSnapshot {
|
export interface FleetSnapshot {
|
||||||
readonly instances: readonly FleetInstance[];
|
readonly instances: readonly FleetInstance[];
|
||||||
@@ -19,6 +20,7 @@ export interface FleetDataSource {
|
|||||||
}
|
}
|
||||||
export interface FleetPageProps {
|
export interface FleetPageProps {
|
||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
|
readonly instanceDataSource?: InstanceDataSource;
|
||||||
readonly initialData?: FleetSnapshot;
|
readonly initialData?: FleetSnapshot;
|
||||||
readonly refreshSignal?: number;
|
readonly refreshSignal?: number;
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,12 @@ export function canonicalHttpOrigin(value: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetPageProps) {
|
export function FleetPage({
|
||||||
|
dataSource,
|
||||||
|
instanceDataSource,
|
||||||
|
initialData,
|
||||||
|
refreshSignal = 0,
|
||||||
|
}: FleetPageProps) {
|
||||||
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(initialData ?? null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
@@ -94,6 +101,13 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
|||||||
const [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
|
const [shownColumns, setShownColumns] = useState<ReadonlySet<string>>(new Set(ALL_COLUMNS));
|
||||||
const [columnsOpen, setColumnsOpen] = useState(false);
|
const [columnsOpen, setColumnsOpen] = useState(false);
|
||||||
const [batchOpen, setBatchOpen] = useState(false);
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<string>();
|
||||||
|
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
|
||||||
|
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const deleteRequestRef = useRef(0);
|
||||||
|
const [deleteStatus, setDeleteStatus] =
|
||||||
|
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,6 +198,47 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function beginDelete(id: string): Promise<void> {
|
||||||
|
const request = ++deleteRequestRef.current;
|
||||||
|
setDeleteId(id);
|
||||||
|
setDeleteOwner(undefined);
|
||||||
|
setDeleteConfirmation('');
|
||||||
|
setDeleteStatus(undefined);
|
||||||
|
if (!instanceDataSource) {
|
||||||
|
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
const current = await instanceDataSource.get(id);
|
||||||
|
if (request !== deleteRequestRef.current) return;
|
||||||
|
if (current.id !== id) throw new Error('owner mismatch');
|
||||||
|
setDeleteOwner({ id: current.id, revision: current.revision });
|
||||||
|
} catch {
|
||||||
|
if (request === deleteRequestRef.current) {
|
||||||
|
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (request === deleteRequestRef.current) setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
setDeleteStatus(undefined);
|
||||||
|
try {
|
||||||
|
await instanceDataSource.delete(id, deleteOwner.revision);
|
||||||
|
setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' });
|
||||||
|
setDeleteId(undefined);
|
||||||
|
setDeleteConfirmation('');
|
||||||
|
} catch {
|
||||||
|
setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' });
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const selectFilter = (
|
const selectFilter = (
|
||||||
label: string,
|
label: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -216,6 +271,9 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
|||||||
<p>查找、对比并管理所有 SimAdmin 实例。</p>
|
<p>查找、对比并管理所有 SimAdmin 实例。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="fleet-actions">
|
<div className="fleet-actions">
|
||||||
|
<a className="primary-action" href="/instances/new">
|
||||||
|
添加实例
|
||||||
|
</a>
|
||||||
<span className="selection-summary" aria-live="polite">
|
<span className="selection-summary" aria-live="polite">
|
||||||
已选择 {model.selectedIds.length} 项
|
已选择 {model.selectedIds.length} 项
|
||||||
</span>
|
</span>
|
||||||
@@ -234,79 +292,86 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="fleet-toolbar">
|
<div className="fleet-toolbar">
|
||||||
<label>
|
<div className="fleet-toolbar-primary">
|
||||||
<span>搜索实例</span>
|
<label>
|
||||||
<input
|
<span>搜索实例</span>
|
||||||
type="search"
|
<input
|
||||||
value={query}
|
type="search"
|
||||||
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
value={query}
|
||||||
placeholder="名称、ID、标签、源地址…"
|
onChange={(event) => resetPage(() => setQuery(event.currentTarget.value))}
|
||||||
/>
|
placeholder="名称、ID、标签、源地址…"
|
||||||
</label>
|
/>
|
||||||
<label>
|
</label>
|
||||||
<span>状态</span>
|
<label>
|
||||||
<select
|
<span>状态</span>
|
||||||
value={filter}
|
<select
|
||||||
onChange={(event) =>
|
value={filter}
|
||||||
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
onChange={(event) =>
|
||||||
}
|
resetPage(() => setFilter(event.currentTarget.value as FleetFilter))
|
||||||
>
|
}
|
||||||
<option value="all">全部状态</option>
|
>
|
||||||
<option value="online">在线</option>
|
<option value="all">全部状态</option>
|
||||||
<option value="auth">需要认证</option>
|
<option value="online">在线</option>
|
||||||
<option value="offline">离线</option>
|
<option value="auth">需要认证</option>
|
||||||
<option value="unknown">未知</option>
|
<option value="offline">离线</option>
|
||||||
</select>
|
<option value="unknown">未知</option>
|
||||||
</label>
|
</select>
|
||||||
<label>
|
</label>
|
||||||
<span>认证</span>
|
|
||||||
<select
|
|
||||||
value={auth}
|
|
||||||
onChange={(event) =>
|
|
||||||
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="all">全部认证状态</option>
|
|
||||||
<option value="authenticated">已认证</option>
|
|
||||||
<option value="required">需要认证</option>
|
|
||||||
<option value="unknown">未知</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{selectFilter(
|
|
||||||
'能力',
|
|
||||||
capability,
|
|
||||||
setCapability,
|
|
||||||
model.facets.capabilities,
|
|
||||||
'全部能力',
|
|
||||||
capabilityLabel,
|
|
||||||
)}
|
|
||||||
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
|
||||||
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
|
||||||
<div className="column-picker">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-expanded={columnsOpen}
|
|
||||||
onClick={() => setColumnsOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
选择列
|
|
||||||
</button>
|
|
||||||
{columnsOpen ? (
|
|
||||||
<fieldset>
|
|
||||||
<legend>显示的列</legend>
|
|
||||||
{ALL_COLUMNS.map((column) => (
|
|
||||||
<label key={column}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
|
||||||
checked={shownColumns.has(column)}
|
|
||||||
onChange={() => toggleColumn(column)}
|
|
||||||
/>
|
|
||||||
{COLUMN_LABELS[column]}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
<details className="fleet-advanced-filters">
|
||||||
|
<summary>更多筛选与列设置</summary>
|
||||||
|
<div className="fleet-advanced-filter-grid">
|
||||||
|
<label>
|
||||||
|
<span>认证</span>
|
||||||
|
<select
|
||||||
|
value={auth}
|
||||||
|
onChange={(event) =>
|
||||||
|
resetPage(() => setAuth(event.currentTarget.value as FleetAuthFilter))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">全部认证状态</option>
|
||||||
|
<option value="authenticated">已认证</option>
|
||||||
|
<option value="required">需要认证</option>
|
||||||
|
<option value="unknown">未知</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectFilter(
|
||||||
|
'能力',
|
||||||
|
capability,
|
||||||
|
setCapability,
|
||||||
|
model.facets.capabilities,
|
||||||
|
'全部能力',
|
||||||
|
capabilityLabel,
|
||||||
|
)}
|
||||||
|
{selectFilter('版本', version, setVersion, model.facets.versions, '全部版本')}
|
||||||
|
{selectFilter('标签', tag, setTag, model.facets.tags, '全部标签')}
|
||||||
|
<div className="column-picker">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={columnsOpen}
|
||||||
|
onClick={() => setColumnsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
选择列
|
||||||
|
</button>
|
||||||
|
{columnsOpen ? (
|
||||||
|
<fieldset>
|
||||||
|
<legend>显示的列</legend>
|
||||||
|
{ALL_COLUMNS.map((column) => (
|
||||||
|
<label key={column}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`显示${COLUMN_LABELS[column]}列`}
|
||||||
|
checked={shownColumns.has(column)}
|
||||||
|
onChange={() => toggleColumn(column)}
|
||||||
|
/>
|
||||||
|
{COLUMN_LABELS[column]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!snapshot && !error ? (
|
{!snapshot && !error ? (
|
||||||
@@ -324,142 +389,267 @@ export function FleetPage({ dataSource, initialData, refreshSignal = 0 }: FleetP
|
|||||||
) : null}
|
) : null}
|
||||||
{snapshot && model.emptyReason ? (
|
{snapshot && model.emptyReason ? (
|
||||||
<div className="state-panel">
|
<div className="state-panel">
|
||||||
{model.emptyReason === 'config' ? <p>尚未配置实例。</p> : null}
|
{model.emptyReason === 'config' ? (
|
||||||
|
<>
|
||||||
|
<p>尚未配置实例。</p>
|
||||||
|
<a className="primary-action" href="/instances/new">
|
||||||
|
添加实例
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{model.emptyReason === 'search' ? <p>没有实例符合搜索与筛选条件。</p> : null}
|
{model.emptyReason === 'search' ? <p>没有实例符合搜索与筛选条件。</p> : null}
|
||||||
{model.emptyReason === 'filter' ? <p>没有实例符合当前筛选条件。</p> : null}
|
{model.emptyReason === 'filter' ? <p>没有实例符合当前筛选条件。</p> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{snapshot && model.rows.length > 0 ? (
|
{snapshot && model.rows.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="table-scroll" tabIndex={0}>
|
<section className="fleet-card-section" aria-label="实例卡片">
|
||||||
<table className="dense-table">
|
<div className="fleet-card-grid">
|
||||||
<caption>实例列表</caption>
|
{model.rows.map((row) => (
|
||||||
<thead>
|
<article
|
||||||
<tr>
|
className="fleet-card"
|
||||||
<th scope="col" className="select-column">
|
aria-label={`${row.displayName} 实例概览`}
|
||||||
<label className="touch-target">
|
key={row.id}
|
||||||
<input
|
>
|
||||||
ref={selectAllRef}
|
<header>
|
||||||
type="checkbox"
|
<div>
|
||||||
aria-label="选择当前页全部实例"
|
<h2>{row.displayName}</h2>
|
||||||
checked={model.visibleSelection.checked}
|
<code>{row.id}</code>
|
||||||
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
</div>
|
||||||
/>
|
<span className={`status status-${row.statusKind}`}>
|
||||||
</label>
|
{STATUS_LABELS[row.statusKind]}
|
||||||
</th>
|
</span>
|
||||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
</header>
|
||||||
<th
|
<dl className="fleet-card-metrics">
|
||||||
key={column}
|
<div>
|
||||||
scope="col"
|
<dt>延迟</dt>
|
||||||
aria-sort={
|
<dd>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</dd>
|
||||||
column !== 'origin' && sort.column === column
|
</div>
|
||||||
? sort.direction === 'asc'
|
<div>
|
||||||
? 'ascending'
|
<dt>版本</dt>
|
||||||
: 'descending'
|
<dd>{row.version ?? '—'}</dd>
|
||||||
: undefined
|
</div>
|
||||||
}
|
<div>
|
||||||
|
<dt>新鲜度</dt>
|
||||||
|
<dd>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="capability-tags" aria-label="能力">
|
||||||
|
{row.capabilities.length > 0 ? (
|
||||||
|
row.capabilities.map((item) => (
|
||||||
|
<span key={item}>{capabilityLabel(item)}</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span>能力未知</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="fleet-card-actions">
|
||||||
|
<a
|
||||||
|
href={`/instances/${encodeURIComponent(row.id)}/overview`}
|
||||||
|
aria-label={`管理 ${row.displayName}`}
|
||||||
>
|
>
|
||||||
{column === 'origin' ? (
|
管理
|
||||||
COLUMN_LABELS[column]
|
</a>
|
||||||
) : (
|
<a
|
||||||
|
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
||||||
|
aria-label={`编辑 ${row.displayName}`}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
aria-label={`删除 ${row.displayName}`}
|
||||||
|
onClick={() => void beginDelete(row.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{deleteId === row.id ? (
|
||||||
|
<div className="card-delete-confirmation">
|
||||||
|
<p>删除后无法撤销。</p>
|
||||||
|
<label>
|
||||||
|
输入 {row.id} 以确认删除
|
||||||
|
<input
|
||||||
|
value={deleteConfirmation}
|
||||||
|
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="fleet-card-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeSort(column)}
|
className="danger-button"
|
||||||
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
disabled={
|
||||||
|
deleteBusy ||
|
||||||
|
deleteOwner?.id !== row.id ||
|
||||||
|
deleteConfirmation !== row.id
|
||||||
|
}
|
||||||
|
aria-label={`确认删除 ${row.displayName}`}
|
||||||
|
onClick={() => void remove(row.id)}
|
||||||
>
|
>
|
||||||
{COLUMN_LABELS[column]}
|
确认删除
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteId(undefined);
|
||||||
|
setDeleteConfirmation('');
|
||||||
|
setDeleteStatus(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{deleteStatus?.id === row.id ? (
|
||||||
|
<p
|
||||||
|
role={deleteStatus.kind === 'error' ? 'alert' : 'status'}
|
||||||
|
className={
|
||||||
|
deleteStatus.kind === 'error'
|
||||||
|
? 'card-operation-error'
|
||||||
|
: 'card-operation-success'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deleteStatus.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="fleet-details" aria-labelledby="fleet-details-title">
|
||||||
|
<h2 id="fleet-details-title">高级详细列表</h2>
|
||||||
|
<div className="table-scroll" tabIndex={0}>
|
||||||
|
<table className="dense-table">
|
||||||
|
<caption>实例列表</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="select-column">
|
||||||
|
<label className="touch-target">
|
||||||
|
<input
|
||||||
|
ref={selectAllRef}
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="选择当前页全部实例"
|
||||||
|
checked={model.visibleSelection.checked}
|
||||||
|
onChange={(event) => toggleVisible(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</th>
|
</th>
|
||||||
))}
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||||
</tr>
|
<th
|
||||||
</thead>
|
key={column}
|
||||||
<tbody>
|
scope="col"
|
||||||
{model.rows.map((row) => {
|
aria-sort={
|
||||||
const origin = canonicalHttpOrigin(row.instance.url);
|
column !== 'origin' && sort.column === column
|
||||||
const cells: Readonly<Record<string, React.ReactNode>> = {
|
? sort.direction === 'asc'
|
||||||
name: (
|
? 'ascending'
|
||||||
<th scope="row">
|
: 'descending'
|
||||||
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
: undefined
|
||||||
{row.displayName}
|
}
|
||||||
</a>
|
>
|
||||||
<small>{row.id}</small>
|
{column === 'origin' ? (
|
||||||
</th>
|
COLUMN_LABELS[column]
|
||||||
),
|
|
||||||
status: (
|
|
||||||
<td>
|
|
||||||
<span className={`status status-${row.statusKind}`}>
|
|
||||||
{STATUS_LABELS[row.statusKind]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
|
||||||
version: <td>{row.version ?? '—'}</td>,
|
|
||||||
capabilities: (
|
|
||||||
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
|
||||||
),
|
|
||||||
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>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeSort(column)}
|
||||||
|
aria-label={`按${COLUMN_LABELS[column]}排序`}
|
||||||
|
>
|
||||||
|
{COLUMN_LABELS[column]}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</th>
|
||||||
),
|
))}
|
||||||
};
|
</tr>
|
||||||
return (
|
</thead>
|
||||||
<tr key={row.id} aria-selected={row.selected}>
|
<tbody>
|
||||||
<td>
|
{model.rows.map((row) => {
|
||||||
<label className="touch-target">
|
const origin = canonicalHttpOrigin(row.instance.url);
|
||||||
<input
|
const cells: Readonly<Record<string, React.ReactNode>> = {
|
||||||
type="checkbox"
|
name: (
|
||||||
aria-label={`选择 ${row.displayName}`}
|
<th scope="row">
|
||||||
checked={row.selected}
|
<a href={`/instances/${encodeURIComponent(row.id)}/overview`}>
|
||||||
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
{row.displayName}
|
||||||
/>
|
</a>
|
||||||
</label>
|
<small>{row.id}</small>
|
||||||
</td>
|
</th>
|
||||||
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
),
|
||||||
<Fragment key={column}>{cells[column]}</Fragment>
|
status: (
|
||||||
))}
|
<td>
|
||||||
</tr>
|
<span className={`status status-${row.statusKind}`}>
|
||||||
);
|
{STATUS_LABELS[row.statusKind]}
|
||||||
})}
|
</span>
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
),
|
||||||
</div>
|
latency: <td>{row.latencyMs === undefined ? '—' : `${row.latencyMs} ms`}</td>,
|
||||||
<nav className="pagination" aria-label="实例分页">
|
version: <td>{row.version ?? '—'}</td>,
|
||||||
<button
|
capabilities: (
|
||||||
type="button"
|
<td>{row.capabilities.map(capabilityLabel).join(', ') || '—'}</td>
|
||||||
aria-label="上一页"
|
),
|
||||||
disabled={model.page === 1}
|
tags: <td>{row.tags.join(', ') || '—'}</td>,
|
||||||
onClick={() => setPage((value) => value - 1)}
|
freshness: <td>{FRESHNESS_LABELS[row.freshness] ?? row.freshness}</td>,
|
||||||
>
|
anomalies: <td>{row.anomalies.join(', ') || '—'}</td>,
|
||||||
上一页
|
origin: (
|
||||||
</button>
|
<td>
|
||||||
<span>
|
{origin ? (
|
||||||
第 {model.page} 页,共 {model.pageCount} 页
|
<a
|
||||||
</span>
|
href={origin}
|
||||||
<button
|
target="_blank"
|
||||||
type="button"
|
rel="noopener noreferrer"
|
||||||
aria-label="下一页"
|
aria-label={`打开 ${row.displayName} 的源站`}
|
||||||
disabled={model.page === model.pageCount}
|
>
|
||||||
onClick={() => setPage((value) => value + 1)}
|
{origin}
|
||||||
>
|
</a>
|
||||||
下一页
|
) : (
|
||||||
</button>
|
<span>源地址无效</span>
|
||||||
</nav>
|
)}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<tr key={row.id} aria-selected={row.selected}>
|
||||||
|
<td>
|
||||||
|
<label className="touch-target">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择 ${row.displayName}`}
|
||||||
|
checked={row.selected}
|
||||||
|
onChange={(event) => toggleOne(row.id, event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
{ALL_COLUMNS.filter((column) => shownColumns.has(column)).map((column) => (
|
||||||
|
<Fragment key={column}>{cells[column]}</Fragment>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<nav className="pagination" aria-label="实例分页">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="上一页"
|
||||||
|
disabled={model.page === 1}
|
||||||
|
onClick={() => setPage((value) => value - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
第 {model.page} 页,共 {model.pageCount} 页
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="下一页"
|
||||||
|
disabled={model.page === model.pageCount}
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export function InstanceDetail({
|
|||||||
打开源站
|
打开源站
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
||||||
<nav aria-label="实例模块">
|
<nav aria-label="实例模块">
|
||||||
<ul>
|
<ul>
|
||||||
{MODULES.map((item) => {
|
{MODULES.map((item) => {
|
||||||
|
|||||||
@@ -262,6 +262,114 @@ main > section > header p {
|
|||||||
small {
|
small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
.primary-action {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid var(--mint-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--mint);
|
||||||
|
box-shadow: 0 5px 0 var(--mint-dark);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.fleet-card-section {
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
.fleet-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.fleet-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: 0 7px 18px rgba(121, 79, 39, 0.09);
|
||||||
|
}
|
||||||
|
.fleet-card > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.fleet-card h2 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
.fleet-card code {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.fleet-card-metrics {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.fleet-card-metrics div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.capability-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.capability-tags span {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #187f77;
|
||||||
|
background: #d8f5ec;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.fleet-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.fleet-card-actions a {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.danger-button {
|
||||||
|
color: #a4423b;
|
||||||
|
border-color: #db938d;
|
||||||
|
}
|
||||||
|
.card-delete-confirmation {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
border-top: 1px solid #db938d;
|
||||||
|
}
|
||||||
|
.card-delete-confirmation p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card-delete-confirmation label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.card-operation-success {
|
||||||
|
color: #087b70;
|
||||||
|
}
|
||||||
|
.card-operation-error {
|
||||||
|
color: #a4423b;
|
||||||
|
}
|
||||||
|
.fleet-details > h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.fleet-toolbar,
|
.fleet-toolbar,
|
||||||
.jobs-toolbar {
|
.jobs-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -285,6 +393,37 @@ small {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: min(100%, 15rem);
|
min-width: min(100%, 15rem);
|
||||||
}
|
}
|
||||||
|
.fleet-toolbar {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fleet-toolbar-primary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 1fr) minmax(9rem, 12rem);
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.fleet-toolbar-primary label:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.fleet-advanced-filters {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
.fleet-advanced-filters > summary {
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--brown);
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fleet-advanced-filter-grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
}
|
||||||
.column-picker {
|
.column-picker {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -552,6 +691,34 @@ main ul[aria-label='已配置实例'] {
|
|||||||
.jobs-toolbar label {
|
.jobs-toolbar label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.fleet-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.fleet-heading > div:first-child p,
|
||||||
|
.selection-summary,
|
||||||
|
.fleet-actions > button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.fleet-toolbar {
|
||||||
|
margin: 0.55rem 0;
|
||||||
|
padding: 0.65rem;
|
||||||
|
}
|
||||||
|
.fleet-toolbar-primary {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 7.5rem;
|
||||||
|
}
|
||||||
|
.fleet-toolbar-primary label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.fleet-toolbar-primary span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.fleet-advanced-filters > summary {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.fleet-card-section {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
}
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -594,6 +761,13 @@ main ul[aria-label='已配置实例'] {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
.fleet-card-actions > * {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.fleet-card {
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user