feat: rebuild warm operations workbench
This commit is contained in:
+131
-30
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user