Files
dsh-web/web/src/Instances.tsx
T
m1saka 602917698a feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
2026-10-17 00:36:21 +08:00

200 lines
15 KiB
TypeScript

import { useState } from 'react';
import { ExternalLink, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { ProfileEditor } from './ProfileEditor';
import { BackupPanel } from './BackupPanel';
import { CreateInstance } from './CreateInstance';
import { api, selectableImage, instancePath, type Instance } from './api';
import { Panel, ResourceState, Empty, Status, bytes, date, useAction, useResource, tableClass, tableHeadRowClass, thClass, tdClass, trClass } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { buttonVariants } from './components/ui/button';
import { inputClass, selectClass, selectStyle } from './components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs';
import { AlertBox } from './components/ui/alert';
import { cn } from './lib/utils';
export function Instances() {
const instances = useResource(api.instances, '', 10000), usage = useResource(api.usage);
const action = useAction(); const [creating, setCreating] = useState(false), [selected, setSelected] = useState<Instance>();
const confirm = useConfirm();
async function operate(i: Instance, operation: string) {
if (operation === 'delete') {
if (!await confirm.ask({title: '删除实例', danger: true, confirmLabel: '永久删除', body: <>确认删除实例「{i.name}」?此操作不可撤销,将永久删除该实例的文件、配置、快照及备份。请先下载需要保留的数据。</>})) return;
} else if (operation !== 'start') {
if (!await confirm.ask({title: '停止或重启实例', confirmLabel: '确认执行', body: <>确认停止或重启「{i.name}」?当前连接将中断。</>})) return;
}
void action.run(async () => {
await api.request(instancePath(i.id) + (operation === 'delete' ? '' : '/' + operation), operation === 'delete' ? 'DELETE' : 'POST');
if (selected?.id === i.id && operation === 'delete') setSelected(undefined);
await instances.refresh();
if (operation === 'delete') await usage.refresh();
}, '服务器已完成操作');
}
const stats = [
{label: '我的实例', value: instances.data ? instances.data.length : '—', suffix: ' / 2', hint: '每个账号最多两个实例'},
{label: '运行中', value: instances.data ? instances.data.filter(i => i.status === 'running').length : '—', suffix: '', hint: '独立的 DSH 运行环境'},
{label: '工作区用量', value: bytes(usage.data?.used_bytes), suffix: '', hint: '配额 ' + bytes(usage.data?.quota_bytes)},
];
return <>
{confirm.element}
<div className="mb-5 grid grid-cols-3 gap-2 sm:gap-4">
{stats.map(s => <div key={s.label} className="rounded-xl border border-border bg-card p-3 shadow-card sm:p-5">
<span className="block text-xs text-muted-foreground">{s.label}</span>
<strong className="mt-0.5 block text-xl font-semibold leading-snug sm:text-[28px]">{s.value}<small className="ml-0.5 text-[13px] font-normal text-muted-foreground">{s.suffix}</small></strong>
<p className="m-0 mt-1 hidden text-[11px] text-muted-foreground sm:block">{s.hint}</p>
</div>)}
</div>
<ResourceState resource={usage} />
{action.feedback}
<Panel title="实例列表" action={<div className="flex items-center gap-1.5">
<button title="刷新" aria-label="刷新" className={cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'text-muted-foreground')} onClick={instances.refresh} disabled={instances.loading}><RefreshCw className={instances.loading ? 'animate-spin' : undefined} aria-hidden /></button>
<button className={cn(buttonVariants({size: 'sm'}))} onClick={() => setCreating(!creating)} disabled={!instances.data || instances.data.length >= 2}><Plus aria-hidden />{creating ? '取消' : '创建实例'}</button>
</div>}>
<ResourceState resource={instances} />
{creating && <CreateInstance close={() => setCreating(false)} refresh={instances.refresh} />}
{instances.data?.length === 0 && <Empty>还没有实例。选择本地镜像,创建你的第一个开发环境。</Empty>}
{!!instances.data?.length && <>
{/* Desktop table */}
<div className="hidden overflow-x-auto md:block">
<table className={tableClass}>
<thead><tr className={tableHeadRowClass}>
<th className={cn(thClass)}>实例</th>
<th className={cn(thClass)}>版本</th>
<th className={cn(thClass)}>状态</th>
<th className={cn(thClass)}>创建时间</th>
<th className={cn(thClass, "text-right")}>操作</th>
</tr></thead>
<tbody>{instances.data.map(i => <tr key={i.id} className={trClass}>
<td className={tdClass}><strong className="text-[13px]">{i.name}</strong><small className="mt-0.5 block font-mono text-[11px] text-muted-foreground">{i.id}</small></td>
<td className={tdClass}>{i.version}</td>
<td className={tdClass}><Status value={i.status} /></td>
<td className="border-b border-border px-3 py-3 align-top whitespace-nowrap">{date(i.created_at)}</td>
<td className={tdClass}><div className="flex flex-wrap items-center justify-end gap-1.5">
{i.status === 'running' ?
<><a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/dsh/' + encodeURIComponent(i.id) + '/'} target="_blank" rel="noreferrer">打开 DSH<ExternalLink aria-hidden /></a>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} onClick={() => void operate(i, 'stop')}>停止</button></> :
<button className={cn(buttonVariants({size: 'sm'}))} disabled={action.busy} onClick={() => void operate(i, 'start')}>启动</button>}
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} onClick={() => setSelected(i)}>管理</button>
<button title="删除实例" aria-label={'删除实例 ' + i.name} className={cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'size-8 text-destructive hover:bg-destructive-muted')} disabled={action.busy} onClick={() => void operate(i, 'delete')}><Trash2 aria-hidden /></button>
</div></td>
</tr>)}</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="grid gap-3 md:hidden">
{instances.data.map(i => <div key={i.id} className="rounded-lg border border-border bg-card p-3.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<strong className="truncate text-[14px]">{i.name}</strong>
<Status value={i.status} />
</div>
<p className="m-0 font-mono text-[11px] text-muted-foreground">{i.id}</p>
<p className="mb-3 mt-1 text-xs text-muted-foreground">版本 {i.version} · 创建于 {date(i.created_at)}</p>
<div className="flex flex-wrap gap-1.5">
{i.status === 'running' ?
<><a className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'flex-1')} href={'/dsh/' + encodeURIComponent(i.id) + '/'} target="_blank" rel="noreferrer">打开 DSH<ExternalLink aria-hidden /></a>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} onClick={() => void operate(i, 'stop')}>停止</button></> :
<button className={cn(buttonVariants({size: 'sm'}), 'flex-1')} disabled={action.busy} onClick={() => void operate(i, 'start')}>启动</button>}
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} onClick={() => setSelected(i)}>管理</button>
<button title="删除实例" aria-label={'删除实例 ' + i.name} className={cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'text-destructive hover:bg-destructive-muted')} disabled={action.busy} onClick={() => void operate(i, 'delete')}><Trash2 aria-hidden /></button>
</div>
</div>)}
</div>
</>}
</Panel>
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={async () => { await Promise.all([instances.refresh(), usage.refresh()]); }} />}
</>;
}
function InstanceDetail({instance, close, refresh}: {instance: Instance; close: () => void; refresh: () => Promise<void>}) {
const [content, setContent] = useState<string>(); const action = useAction();
const confirm = useConfirm();
const base = instancePath(instance.id);
const stopped = ['stopped', 'exited', 'created'].includes(instance.status);
const images = useResource(api.images);
const available = images.data?.filter(selectableImage) || [];
const [upgradeImage, setUpgradeImage] = useState('');
const [upgradePassword, setUpgradePassword] = useState('');
const profile = useResource(() => api.request<{content: string}>(base + '/profile'));
const preview = useResource(() => api.request<{bundles: unknown; config: string; preview_error?: string}>(base + '/profile-preview'));
const logs = useResource(() => api.request<{logs: string}>(base + '/logs'));
return <Panel title={instance.name + ' · 实例管理'} action={<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}))} onClick={close}>关闭</button>}>
{confirm.element}
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">Profile 配置</TabsTrigger>
<TabsTrigger value="preview">配置预览</TabsTrigger>
<TabsTrigger value="backups">备份与恢复</TabsTrigger>
<TabsTrigger value="upgrade">版本升级</TabsTrigger>
<TabsTrigger value="logs">运行日志</TabsTrigger>
</TabsList>
{action.feedback}
<TabsContent value="profile">
<AlertBox variant="info" className="mb-4">
Profile 写入前需先停止实例。当前状态:<Status value={instance.status} />
{!stopped && <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'ml-2')} disabled={action.busy} onClick={async () => {
if (!await confirm.ask({title: '停止实例', confirmLabel: '停止实例', body: '停止将中断当前连接,是否继续?'})) return;
void action.run(async () => { await api.request(base + '/stop', 'POST'); await refresh(); }, '停止请求已完成,请确认实例状态。');
}}>停止实例</button>}
</AlertBox>
<p className="mb-3 text-[13px] text-muted-foreground">profiles/web/cordis.patch.yml · 停止实例后保存,保存后自动启动验证。密钥原文不在此展示。</p>
<ResourceState resource={profile} />
{profile.data && <>
<label className="mb-3 block text-[13px] font-medium">YAML 配置<ProfileEditor value={content ?? profile.data.content} onChange={setContent} readOnly={action.busy} /></label>
<div className="flex flex-wrap gap-2">
<button className={cn(buttonVariants())} disabled={action.busy || !stopped || content === undefined} onClick={async () => {
if (!await confirm.ask({title: '覆盖 Profile 配置', confirmLabel: '保存并启动', body: '确认覆盖当前 Profile 配置?保存后实例将自动启动验证。'})) return;
void action.run(async () => { await api.request(base + '/profile', 'PUT', {content, restart: true}); await profile.refresh(); setContent(undefined); await refresh(); }, '配置已保存并启动验证。');
}}>保存并启动</button>
<button className={cn(buttonVariants({variant: 'outline'}))} disabled={action.busy} onClick={async () => {
if (!await confirm.ask({title: '重启实例', confirmLabel: '重启实例', body: '重启将中断当前连接,是否继续?'})) return;
void action.run(async () => { await api.request(base + '/restart', 'POST'); await refresh(); }, '实例已重启');
}}>重启实例</button>
</div>
</>}
</TabsContent>
<TabsContent value="preview">
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'mb-3')} onClick={preview.refresh}>刷新预览</button>
<ResourceState resource={preview} />
{preview.data && <>
<h3 className="mb-2 text-[14px] font-semibold">Bundle 清单</h3>
<pre className="max-h-72 overflow-auto rounded-lg bg-muted p-3 font-mono text-xs">{JSON.stringify(preview.data.bundles, null, 2)}</pre>
{preview.data.preview_error && <p role="status" className="text-[13px] text-muted-foreground">{preview.data.preview_error}</p>}
<ProfileEditor value={preview.data.config} readOnly label="配置预览(字符串已脱敏)" />
</>}
</TabsContent>
<TabsContent value="backups">
<BackupPanel instance={instance} refresh={refresh} onRestored={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
</TabsContent>
<TabsContent value="upgrade">
<form className="grid max-w-xl gap-4" onSubmit={async e => {
e.preventDefault();
if (!available.some(i => (i.image_id || i.id) === upgradeImage)) return;
if (!await confirm.ask({title: '升级实例', confirmLabel: '升级实例', body: '确认使用所选本地镜像升级?建议先备份配置。升级会自动编排停启并保留 home 快照。'})) return;
void action.run(async () => {
try { await api.request(base + '/upgrade', 'POST', {image_id: upgradeImage, password: upgradePassword}); await refresh(); }
finally { setUpgradePassword(''); }
}, '实例升级请求已完成。');
}}>
<p className="m-0 text-[13px]">当前版本:<strong>{instance.version}</strong></p>
<AlertBox variant="info">仅已准入且本地可用的镜像可用于升级。历史镜像仅允许存量实例运行,不可用于新建或升级。</AlertBox>
<label className="grid gap-1.5 text-[13px] font-medium">当前登录密码(加密升级前备份)
<input className={inputClass} type="password" required autoComplete="current-password" value={upgradePassword} onChange={e => setUpgradePassword(e.target.value)} />
</label>
<ResourceState resource={images} />
<label className="grid gap-1.5 text-[13px] font-medium">本地镜像
<select className={selectClass} style={selectStyle} required value={upgradeImage} onChange={e => setUpgradeImage(e.target.value)}>
<option value="" disabled>选择可用镜像</option>
{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}
</select>
</label>
<div><button className={cn(buttonVariants())} disabled={action.busy || !upgradeImage}>升级实例</button></div>
</form>
</TabsContent>
<TabsContent value="logs">
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'mb-3')} onClick={logs.refresh} disabled={logs.loading}>刷新日志</button>
<ResourceState resource={logs} />
<pre className="scrollbar-thin max-h-[26rem] min-h-32 overflow-auto whitespace-pre-wrap rounded-lg bg-nav p-4 font-mono text-xs leading-relaxed text-[#b7d8d3] wrap-anywhere">{logs.data?.logs || '暂无日志'}</pre>
</TabsContent>
</Tabs>
</Panel>;
}