feat: rebuild warm operations workbench

This commit is contained in:
Codex
2026-07-30 14:49:33 +08:00
parent 25c853dea8
commit 570edf6bb2
88 changed files with 13413 additions and 2250 deletions
+131 -30
View File
@@ -3,6 +3,13 @@ import { AuditPage, type AuditDataSource } from './audit/audit-page.js';
import { createAuditApiDataSource } from './audit/audit-api-data-source.js';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Tag } from 'animal-island-ui';
import { Icon } from './ui/icon.js';
import {
AutomationPage,
type AutomationDataSource as ScheduleDataSource,
} from './automation/automation-page.js';
import { createAutomationApiDataSource } from './automation/automation-api-data-source.js';
import { useControlPlaneEvents } from './events/use-control-plane-events.js';
import { createEventStreamClient, type EventStreamClient } from './events/event-stream-client.js';
@@ -41,7 +48,7 @@ import {
type InstanceCapabilityMap,
} from './instances/instance-detail.js';
export type GlobalSection = 'fleet' | 'jobs' | 'audit' | 'settings';
export type GlobalSection = 'fleet' | 'automation' | 'settings';
export type InstanceModule =
| 'overview'
| 'cellular'
@@ -55,6 +62,7 @@ export type InstanceModule =
export type RouteKind =
| 'redirect'
| 'fleet'
| 'automation'
| 'instance-new'
| `instance-${InstanceModule}`
| 'jobs'
@@ -109,6 +117,7 @@ export interface AppShellProps {
otaDataSource?: OtaDataSource;
jobsDataSource?: JobsDataSource;
auditDataSource?: AuditDataSource;
scheduleDataSource?: ScheduleDataSource;
eventStreamClient?: EventStreamClient;
}
@@ -132,6 +141,7 @@ export function resolveRoute(input: string): ResolvedRoute {
if (pathname === '/') return { kind: 'redirect', pathname, to: '/fleet' };
const staticRoutes: Readonly<Record<string, RouteKind>> = {
'/fleet': 'fleet',
'/automation': 'automation',
'/instances/new': 'instance-new',
'/jobs': 'jobs',
'/audit': 'audit',
@@ -162,8 +172,8 @@ export function resolveRoute(input: string): ResolvedRoute {
function section(route: ResolvedRoute): GlobalSection | undefined {
if (route.kind === 'fleet' || route.kind === 'instance-new' || route.kind.startsWith('instance-'))
return 'fleet';
if (route.kind.startsWith('job')) return 'jobs';
if (route.kind.startsWith('audit')) return 'audit';
if (route.kind === 'automation' || route.kind.startsWith('job') || route.kind.startsWith('audit'))
return 'automation';
if (route.kind.startsWith('settings')) return 'settings';
return undefined;
}
@@ -188,6 +198,7 @@ function Page({
otaDataSource,
jobsDataSource,
auditDataSource,
scheduleDataSource,
fleetRefreshSignal,
detailRefreshSignal,
}: {
@@ -210,6 +221,7 @@ function Page({
otaDataSource: OtaDataSource | undefined;
jobsDataSource: JobsDataSource | undefined;
auditDataSource: AuditDataSource | undefined;
scheduleDataSource: ScheduleDataSource;
fleetRefreshSignal: number;
detailRefreshSignal: number;
}): ReactNode {
@@ -237,18 +249,25 @@ function Page({
{...(instanceDataSource ? { dataSource: instanceDataSource } : {})}
/>
);
if (route.kind === 'jobs')
if (route.kind === 'automation' || route.kind === 'jobs' || route.kind === 'audit')
return (
<JobsPage
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
);
if (route.kind === 'audit')
return (
<AuditPage
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
refreshSignal={fleetRefreshSignal}
<AutomationPage
dataSource={scheduleDataSource}
initialTab={
route.kind === 'jobs' ? 'runs' : route.kind === 'audit' ? 'records' : 'schedules'
}
runsContent={
<JobsPage
{...(jobsDataSource ? { dataSource: jobsDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
}
recordsContent={
<AuditPage
{...(auditDataSource ? { dataSource: auditDataSource } : {})}
refreshSignal={fleetRefreshSignal}
/>
}
/>
);
if (route.kind === 'settings-instances')
@@ -405,6 +424,33 @@ function displayConsoleVersion(version: string | undefined): string | undefined
return trimmed;
}
const GLOBAL_NAVIGATION: readonly {
section: GlobalSection;
href: string;
label: string;
icon: 'grid' | 'jobs' | 'settings';
}[] = [
{ section: 'fleet', href: '/fleet', label: '节点', icon: 'grid' },
{ section: 'automation', href: '/automation', label: '自动化', icon: 'jobs' },
{ section: 'settings', href: '/settings/instances', label: '设置', icon: 'settings' },
];
const STREAM_LABELS = {
connecting: '正在连接',
open: '实时连接正常',
reconnecting: '正在重新连接',
resetting: '正在同步状态',
closed: '实时连接已关闭',
} as const;
const STREAM_COLORS = {
connecting: 'app-yellow',
open: 'app-teal',
reconnecting: 'app-orange',
resetting: 'app-yellow',
closed: 'app-red',
} as const;
export function AppShell({
pathname,
version = 'dev',
@@ -426,6 +472,7 @@ export function AppShell({
otaDataSource,
jobsDataSource,
auditDataSource,
scheduleDataSource,
eventStreamClient,
}: AppShellProps) {
const defaultEventStreamClient = useMemo(() => createEventStreamClient(), []);
@@ -441,6 +488,10 @@ export function AppShell({
() => auditDataSource ?? createAuditApiDataSource(),
[auditDataSource],
);
const resolvedScheduleDataSource = useMemo(
() => scheduleDataSource ?? createAutomationApiDataSource(),
[scheduleDataSource],
);
const resolved = resolveRoute(pathname);
const route = resolved.kind === 'redirect' ? resolveRoute(resolved.to ?? '/fleet') : resolved;
const routeInstanceId = route.params?.instanceId;
@@ -598,29 +649,71 @@ export function AppShell({
</a>
<header className="app-topbar">
<a className="product-name" href="/fleet">
SimAdmin
</a>
<div className="topbar-actions">
<span className="connection-status"></span>
{consoleVersion ? (
<span className="version-badge" aria-label={`控制台版本 ${consoleVersion}`}>
v{consoleVersion}
<a className="product-name" href="/fleet" aria-label="多实例 SimAdmin 管理台首页">
<span className="product-mark" aria-hidden="true">
<span className="product-signal">
<i />
<i />
<i />
</span>
) : null}
<a
className="topbar-settings"
href="/settings/system"
aria-current={currentSection === 'settings' ? 'page' : undefined}
</span>
<span className="product-copy">
<strong>SimAdmin Control</strong>
<small></small>
</span>
</a>
<nav className="global-navigation" aria-label="全局导航">
{GLOBAL_NAVIGATION.map((item) => (
<a
key={item.section}
href={item.href}
aria-current={currentSection === item.section ? 'page' : undefined}
>
<Icon name={item.icon} />
<span>{item.label}</span>
</a>
))}
</nav>
<div className="topbar-actions">
<Tag
className="connection-status"
color={STREAM_COLORS[refresh.stream]}
variant="soft"
size="small"
>
</a>
<span data-state={refresh.stream} role="status" aria-label="实时连接状态">
{STREAM_LABELS[refresh.stream]}
</span>
</Tag>
{consoleVersion ? (
<Tag className="version-badge" color="brown" variant="soft" size="small">
<span aria-label={`控制台版本 ${consoleVersion}`}>v{consoleVersion}</span>
</Tag>
) : null}
</div>
</header>
<div className="app-layout app-layout-single">
<main id="main-content" tabIndex={-1}>
{currentSection === 'settings' ? (
<nav className="settings-navigation" aria-label="设置导航">
<a
href="/settings/instances"
aria-current={route.kind === 'settings-system' ? undefined : 'page'}
>
</a>
<a
href="/settings/system"
aria-current={route.kind === 'settings-system' ? 'page' : undefined}
>
</a>
</nav>
) : null}
{routeInstanceId && instanceLoading ? (
<p role="status"></p>
<p role="status" aria-label="实例加载状态">
</p>
) : routeInstanceId && instanceLoadFailed ? (
<p role="alert" className="state-panel state-error">
@@ -646,12 +739,20 @@ export function AppShell({
otaDataSource={otaDataSource}
jobsDataSource={resolvedJobsDataSource}
auditDataSource={resolvedAuditDataSource}
scheduleDataSource={resolvedScheduleDataSource}
fleetRefreshSignal={refresh.fleet}
detailRefreshSignal={refresh.detail}
/>
)}
</main>
</div>
<footer className="app-footer" aria-label="项目与组件库信息">
<p>
SimAdmin · {' '}
<a href="https://github.com/guokaigdg/animal-island-ui">animal-island-ui</a>
CC BY-NC 4.0使
</p>
</footer>
</div>
);
}