feat: redesign responsive console and fix restore regressions

This commit is contained in:
2026-10-15 01:08:55 +08:00
parent 3bf197206a
commit 56c8f76e32
49 changed files with 3455 additions and 447 deletions
+181 -8
View File
@@ -1,25 +1,198 @@
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 } from './ui';
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>();
function operate(i: Instance, operation: string) { 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();}, '服务器已完成操作', operation === 'delete' ? '确认删除实例「' + i.name + '」?此操作不可撤销。' : operation !== 'start' ? '确认停止或重启「' + i.name + '」?当前连接将中断。' : undefined); }
return <><div className="stats"><div><span>我的实例</span><strong>{instances.data ? instances.data.length : '—'}<small> / 2</small></strong><p>每个账号最多两个实例</p></div><div><span>运行中</span><strong>{instances.data ? instances.data.filter(i => i.status === 'running').length : '—'}</strong><p>独立的 DSH 运行环境</p></div><div><span>工作区用量</span><strong className="size-value">{bytes(usage.data?.used_bytes)}</strong><p>配额 {bytes(usage.data?.quota_bytes)}</p></div></div><ResourceState resource={usage} />{action.feedback}<Panel title="实例列表" action={<div className="actions"><button onClick={instances.refresh} disabled={instances.loading}>刷新</button><button className="primary" onClick={() => setCreating(!creating)} disabled={!instances.data || instances.data.length >= 2}>{creating ? '取消' : '+ 创建实例'}</button></div>}><ResourceState resource={instances} />{creating && <CreateInstance close={() => setCreating(false)} refresh={instances.refresh}/>}{instances.data?.length === 0 && <Empty>还没有实例。选择本地镜像,创建你的第一个开发环境。</Empty>}{!!instances.data?.length && <div className="table-scroll"><table><thead><tr><th>实例</th><th>版本</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead><tbody>{instances.data.map(i => <tr key={i.id}><td><strong>{i.name}</strong><small className="mono">{i.id}</small></td><td>{i.version}</td><td><Status value={i.status}/></td><td>{date(i.created_at)}</td><td><div className="actions">{i.status === 'running' ? <><a className="button" href={'/dsh/' + encodeURIComponent(i.id) + '/'} target="_blank" rel="noreferrer">打开 DSH ↗</a><button disabled={action.busy} onClick={() => operate(i,'stop')}>停止</button></> : <button disabled={action.busy} onClick={() => operate(i,'start')}>启动</button>}<button onClick={() => setSelected(i)}>管理</button><button className="danger" disabled={action.busy} onClick={() => operate(i,'delete')}>删除</button></div></td></tr>)}</tbody></table></div>}</Panel>{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}</>;
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();
}, '服务器已完成操作');
}
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={instances.refresh} />}
</>;
}
function InstanceDetail({instance, close, refresh}: {instance: Instance; close: () => void; refresh: () => Promise<void>}) {
const [tab, setTab] = useState('profile'); const [content, setContent] = useState<string>(); const action = useAction();
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 onClick={close}>关闭</button>}><div className="tabs">{[['profile','Profile 配置'],['preview','配置预览'],['backups','备份与恢复'],['upgrade','版本升级'],['logs','运行日志']].map(([id,label]) => <button className={tab === id ? 'active' : ''} onClick={() => setTab(id)} key={id}>{label}</button>)}</div>{action.feedback}{tab === 'profile' && <div className="callout">Profile 写入前需先停止实例。当前状态:<Status value={instance.status}/>{!stopped && <button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/stop','POST'); await refresh();}, '停止请求已完成,请确认实例状态。', '停止将中断当前连接,是否继续?')}>停止实例</button>}</div>}{tab === 'profile' && <><p className="muted">profiles/web/cordis.patch.yml · 停止实例后保存,保存后自动启动验证。密钥原文不在此展示。</p><ResourceState resource={profile} />{profile.data && <><label>YAML 配置<ProfileEditor value={content ?? profile.data.content} onChange={setContent} readOnly={action.busy}/></label><div className="actions"><button className="primary" disabled={action.busy || !stopped || content === undefined} onClick={() => action.run(async () => {await api.request(base + '/profile','PUT',{content,restart:true}); await profile.refresh(); setContent(undefined); await refresh();}, '配置已保存并启动验证。', '确认覆盖当前 Profile 配置?')}>保存并启动</button><button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/restart','POST'); await refresh();}, '实例已重启', '重启将中断当前连接,是否继续?')}>重启实例</button></div></>}</>}{tab === 'preview' && <><button onClick={preview.refresh}>刷新预览</button><ResourceState resource={preview}/>{preview.data && <><h3>Bundle 清单</h3><pre>{JSON.stringify(preview.data.bundles,null,2)}</pre>{preview.data.preview_error && <p role="status">{preview.data.preview_error}</p>}<ProfileEditor value={preview.data.config} readOnly label="配置预览(字符串已脱敏)"/></>}</>}{tab === 'backups' && <BackupPanel instance={instance} refresh={async () => {await profile.refresh(); setContent(undefined); await refresh();}}/>}{tab === 'upgrade' && <form className="inline-form" onSubmit={e => {e.preventDefault(); if (!available.some(i => (i.image_id || i.id) === upgradeImage)) return; void action.run(async () => {try {await api.request(base + '/upgrade','POST',{image_id:upgradeImage,password:upgradePassword}); await refresh();} finally {setUpgradePassword('');}}, '实例升级请求已完成。', '确认使用所选本地镜像升级?建议先备份配置。');}}><p>当前版本:{instance.version}</p><p className="callout">仅已准入且本地可用的镜像可用于升级。历史镜像仅允许存量实例运行,不可用于新建或升级。</p><label>当前登录密码(加密升级前备份)<input type="password" required autoComplete="current-password" value={upgradePassword} onChange={e => setUpgradePassword(e.target.value)}/></label><ResourceState resource={images}/><label>本地镜像<select 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><button className="primary" disabled={action.busy || !upgradeImage}>升级实例</button></form>}{tab === 'logs' && <><button onClick={logs.refresh} disabled={logs.loading}>刷新日志</button><ResourceState resource={logs}/><pre className="logs">{logs.data?.logs || '暂无日志'}</pre></>}</Panel>;
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={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>;
}