feat: rebuild warm operations workbench
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Button, Card, Title } from 'animal-island-ui';
|
||||
|
||||
import {
|
||||
createInstanceApiDataSource,
|
||||
@@ -146,7 +147,9 @@ export function InstanceEditor({
|
||||
|
||||
return (
|
||||
<section className="instance-editor">
|
||||
<h1>{mode === 'create' ? '添加实例' : '实例设置'}</h1>
|
||||
<h1>
|
||||
<Title color="app-green">{mode === 'create' ? '添加实例' : '实例设置'}</Title>
|
||||
</h1>
|
||||
{error ? (
|
||||
<p role="alert" className="state-panel state-error">
|
||||
{error}
|
||||
@@ -157,82 +160,84 @@ export function InstanceEditor({
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
名称
|
||||
<input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
源地址
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
aria-describedby="tags-help"
|
||||
/>
|
||||
</label>
|
||||
<small id="tags-help">使用英文逗号分隔标签</small>
|
||||
<label>
|
||||
认证方式
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
|
||||
>
|
||||
<option value="none">无</option>
|
||||
<option value="password">密码</option>
|
||||
</select>
|
||||
</label>
|
||||
{authMethod === 'password' ? (
|
||||
<>
|
||||
{mode === 'edit' ? (
|
||||
<label>
|
||||
密码操作
|
||||
<select
|
||||
value={passwordAction}
|
||||
onChange={(event) => {
|
||||
setPasswordAction(event.target.value as PasswordAction);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
<option value="preserve">保留已保存的密码</option>
|
||||
<option value="set">设置新密码</option>
|
||||
<option value="clear">清除已保存的密码</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{mode === 'create' || passwordAction === 'set' ? (
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<div className="form-actions">
|
||||
<button disabled={busy} type="submit">
|
||||
{mode === 'create' ? '添加实例' : '保存更改'}
|
||||
</button>
|
||||
{mode === 'edit' ? (
|
||||
<button disabled={busy} type="button" onClick={() => void testConnection()}>
|
||||
测试连接
|
||||
</button>
|
||||
<Card pattern="default" className="editor-card">
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
名称
|
||||
<input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
源地址
|
||||
<input
|
||||
required
|
||||
type="url"
|
||||
value={origin}
|
||||
onChange={(event) => setOrigin(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
标签
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
aria-describedby="tags-help"
|
||||
/>
|
||||
</label>
|
||||
<small id="tags-help">使用英文逗号分隔标签</small>
|
||||
<label>
|
||||
认证方式
|
||||
<select
|
||||
value={authMethod}
|
||||
onChange={(event) => setAuthMethod(event.target.value as 'none' | 'password')}
|
||||
>
|
||||
<option value="none">无</option>
|
||||
<option value="password">密码</option>
|
||||
</select>
|
||||
</label>
|
||||
{authMethod === 'password' ? (
|
||||
<>
|
||||
{mode === 'edit' ? (
|
||||
<label>
|
||||
密码操作
|
||||
<select
|
||||
value={passwordAction}
|
||||
onChange={(event) => {
|
||||
setPasswordAction(event.target.value as PasswordAction);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
<option value="preserve">保留已保存的密码</option>
|
||||
<option value="set">设置新密码</option>
|
||||
<option value="clear">清除已保存的密码</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{mode === 'create' || passwordAction === 'set' ? (
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
<div className="form-actions">
|
||||
<Button disabled={busy} loading={busy} htmlType="submit" type="primary">
|
||||
{mode === 'create' ? '添加实例' : '保存更改'}
|
||||
</Button>
|
||||
{mode === 'edit' ? (
|
||||
<Button disabled={busy} htmlType="button" onClick={() => void testConnection()}>
|
||||
测试连接
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
{mode === 'edit' ? (
|
||||
<section className="danger-zone" aria-labelledby="danger-heading">
|
||||
<h2 id="danger-heading">危险操作区</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Tag, Title } from 'animal-island-ui';
|
||||
|
||||
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
||||
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
|
||||
@@ -124,7 +125,9 @@ export function InstanceDetail({
|
||||
if (!ownsRoute) {
|
||||
return (
|
||||
<section>
|
||||
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
||||
<h1>
|
||||
<Title>{INSTANCE_MODULE_LABELS[module]}</Title>
|
||||
</h1>
|
||||
<p>此路由缺少实例上下文。</p>
|
||||
</section>
|
||||
);
|
||||
@@ -142,16 +145,35 @@ export function InstanceDetail({
|
||||
</a>
|
||||
<div className="instance-identity">
|
||||
<div>
|
||||
<h1>{instance.name}</h1>
|
||||
<h1>
|
||||
<Title color="app-teal">{instance.name}</Title>
|
||||
</h1>
|
||||
<code>{instance.id}</code>
|
||||
</div>
|
||||
<div className="instance-context-badges">
|
||||
{instance.status !== 'unknown' ? <span>{displayStatus(instance.status)}</span> : null}
|
||||
{instance.status !== 'unknown' ? (
|
||||
<Tag
|
||||
size="small"
|
||||
color={
|
||||
instance.status === 'online'
|
||||
? 'app-teal'
|
||||
: instance.status === 'offline'
|
||||
? 'app-red'
|
||||
: 'app-yellow'
|
||||
}
|
||||
>
|
||||
{displayStatus(instance.status)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{instance.authentication !== 'unknown' ? (
|
||||
<span>{AUTH_LABELS[instance.authentication]}</span>
|
||||
<Tag size="small" color="app-yellow">
|
||||
{AUTH_LABELS[instance.authentication]}
|
||||
</Tag>
|
||||
) : null}
|
||||
{instance.freshness !== 'unknown' ? (
|
||||
<span>{FRESHNESS_LABELS[instance.freshness]}</span>
|
||||
<Tag size="small" color="app-blue">
|
||||
{FRESHNESS_LABELS[instance.freshness]}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,7 +207,11 @@ export function InstanceDetail({
|
||||
</nav>
|
||||
</header>
|
||||
<div className="instance-module-detail">
|
||||
<h2>{INSTANCE_MODULE_LABELS[module]}</h2>
|
||||
<h2>
|
||||
<Title size="small" color="app-green">
|
||||
{INSTANCE_MODULE_LABELS[module]}
|
||||
</Title>
|
||||
</h2>
|
||||
{loading ? <p role="status">正在加载能力…</p> : null}
|
||||
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
||||
{!loading && canOpen(module, activeCapability) ? (
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createMessagesApiDataSource(options: Options = {}): MessagesData
|
||||
async load(instanceId, signal): Promise<MessagesSnapshot> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/instances/${encodeURIComponent(instanceId)}/messages?limit=50&offset=0`,
|
||||
{ headers: { accept: 'application/json' }, signal },
|
||||
{ headers: { accept: 'application/json' }, credentials: 'same-origin', signal },
|
||||
);
|
||||
if (!response.ok) throw new Error('message load failed');
|
||||
const root = record(await response.json());
|
||||
@@ -50,6 +50,7 @@ export function createMessagesApiDataSource(options: Options = {}): MessagesData
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -243,12 +243,12 @@ export function MessagesModule({ instance, dataSource, refreshSignal }: Messages
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
maxLength={1600}
|
||||
maxLength={2000}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="composer-footer">
|
||||
<small>{content.length} / 1600 字符</small>
|
||||
<small>{content.length} / 2000 字符</small>
|
||||
<button type="submit" disabled={sending || !content.trim()}>
|
||||
{sending ? '正在发送…' : '发送短信'}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { InstanceContext } from '../app-shell.js';
|
||||
import {
|
||||
createOperationClient,
|
||||
type OperationClient,
|
||||
} from '../operations/operation-client.js';
|
||||
import { createOperationClient, type OperationClient } from '../operations/operation-client.js';
|
||||
import { safeUiError } from '../ui/locale.js';
|
||||
|
||||
export type OverviewFieldValue = string | number | boolean | null;
|
||||
@@ -51,11 +48,22 @@ const SECTIONS = [
|
||||
|
||||
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
Model: '型号',
|
||||
Manufacturer: '制造商',
|
||||
IMEI: 'IMEI',
|
||||
Version: '固件版本',
|
||||
Uptime: '运行时间',
|
||||
Slots: '卡槽数',
|
||||
Active: '活跃数',
|
||||
Operator: '运营商',
|
||||
Technology: '接入制式',
|
||||
Signal: '信号强度',
|
||||
MCC: 'MCC',
|
||||
MNC: 'MNC',
|
||||
Registration: '注册状态',
|
||||
IPv4: 'IPv4 地址',
|
||||
IPv6: 'IPv6 地址',
|
||||
Download: '下行速率',
|
||||
Upload: '上行速率',
|
||||
'Messages today': '今日消息数',
|
||||
Calls: '通话数',
|
||||
Usage: '使用率',
|
||||
@@ -214,7 +222,8 @@ export function OverviewSystemPage({
|
||||
return;
|
||||
setActionState({ busy: true, message: `正在${op.title}…` });
|
||||
try {
|
||||
await client.list({ pageSize: 100 });
|
||||
// Avoid pageSize=100 first-page truncation for late-sorted operation ids.
|
||||
await client.list({ search: op.operationId, pageSize: 100 });
|
||||
const prepared = await client.prepare({
|
||||
operationId: op.operationId,
|
||||
targets: [{ instanceId: instance.id, revision: targetRevision }],
|
||||
@@ -235,7 +244,8 @@ export function OverviewSystemPage({
|
||||
const job = await client.execute(prepared.id);
|
||||
setActionState({
|
||||
busy: false,
|
||||
message: job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
|
||||
message:
|
||||
job.status === 'succeeded' ? `${op.title}已提交成功。` : `${op.title}结果未知或失败。`,
|
||||
...(job.status === 'succeeded' ? {} : { error: `${op.title}结果未知或失败。` }),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user