Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { useState } from 'react';
import { ProfileEditor } from './ProfileEditor';
import { BackupPanel } from './BackupPanel';
import { CreateInstance } from './CreateInstance';
import { api, instancePath, type Instance } from './api';
import { Panel, ResourceState, Empty, Status, bytes, date, useAction, useResource } from './ui';
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} />}</>;
}
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 base = instancePath(instance.id);
const stopped = ['stopped', 'exited', 'created'].includes(instance.status);
const images = useResource(api.images);
const available = images.data?.filter(i => !i.status || i.status === 'available') || [];
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><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>;
}