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
+1467 -75
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -10,9 +10,20 @@
"test": "vitest run"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.3.1",
"@radix-ui/react-dropdown-menu": "^2.2.1",
"@radix-ui/react-label": "^2.1.17",
"@radix-ui/react-slot": "^1.5.0",
"@radix-ui/react-tabs": "^1.2.1",
"@tailwindcss/vite": "^4.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.57.0",
"monaco-editor": "^0.52.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^26.6.5",
+26
View File
@@ -0,0 +1,26 @@
import { expect, it, vi } from 'vitest';
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState: (value: unknown) => [value, vi.fn()],
useRef: (value: unknown) => ({current: value}),
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: () => ({data: [{id: 'one', username: 'alice'}, {id: 'two', username: 'bob'}], loading: false, refresh: vi.fn()}),
}));
import { Admin } from './Admin';
import { ResetPassword } from './ResetPassword';
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
it('mounts one reset-password owner per user in a shared responsive list', () => {
const accounts = (Admin().props.children as any[])[0];
const tree = accounts.type(accounts.props);
const editors = nodes(tree, n => n.type === ResetPassword);
expect(editors.map(n => n.props.user.id)).toEqual(['one', 'two']);
for (const branch of nodes(tree, n => typeof n.props?.className === 'string' && /md:hidden|md:block/.test(n.props.className))) {
expect(nodes(branch, n => n.type === ResetPassword)).toHaveLength(0);
}
});
+96 -5
View File
@@ -5,13 +5,104 @@ import { passwordResetError } from './passwordReset';
import { Orphans } from './Orphans';
import { ResetPassword } from './ResetPassword';
import { api, type User, type Audit } from './api';
import { Panel, ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { Panel, ResourceState, Empty, bytes, date, useAction, useResource, RefreshButton, tableClass, tableHeadRowClass, thClass, tdClass, trClass } from './ui';
import { AlertBox } from './components/ui/alert';
import { buttonVariants } from './components/ui/button';
import { inputClass } from './components/ui/input';
import { cn } from './lib/utils';
export function Admin() {
return <><AdminAccounts/><Orphans/></>;
return <><AdminAccounts /><Orphans /></>;
}
function AdminAccounts() {
const users = useResource(() => api.request<User[]>('/users')), audit = useResource(() => api.request<Audit[]>('/audit')); const action = useAction();
const users = useResource(() => api.request<User[]>('/users')), audit = useResource(() => api.request<Audit[]>('/audit'));
const action = useAction();
const [quotaUser, setQuotaUser] = useState<User | null>(null), [notice, setNotice] = useState('');
function create(e: FormEvent<HTMLFormElement>) {e.preventDefault(); const element = e.currentTarget, form = new FormData(element); void action.run(async () => {const password = String(form.get('password') || ''); const error = passwordResetError(password, password); if (error) throw new Error(error); const quota = quotaBytes(String(form.get('quota'))); if (quota === null) throw new Error('请输入有效配额'); await api.request('/users','POST',{username:form.get('username'),password:form.get('password'),admin:form.get('admin') === 'on',quota_bytes:quota}); element.reset(); await Promise.all([users.refresh(),audit.refresh()]);}, '账号已创建');}
return <>{action.feedback}{notice && <div className="notice" role="status">{notice}</div>}{quotaUser && <QuotaDialog user={quotaUser} onClose={() => setQuotaUser(null)} onSaved={() => {setNotice(quotaUser.username + ' 的配额已更新'); void users.refresh(); void audit.refresh();}}/>}<Panel title="创建账号"><form className="user-form" onSubmit={create}><label>用户名(3–40 位字母、数字、_ 或 -)<input name="username" required minLength={3} maxLength={40} pattern="[A-Za-z0-9_\-]{3,40}" disabled={action.busy} autoComplete="off"/></label><label>初始密码(12–72 UTF-8 字节)<input name="password" type="password" disabled={action.busy} required autoComplete="new-password"/></label><label>工作区配额(GiB)<input name="quota" disabled={action.busy} type="number" min="0.1" step="0.1" required defaultValue="10"/></label><label className="checkbox"><input name="admin" disabled={action.busy} type="checkbox"/>管理员(拥有平台管理权限)</label><button className="primary" disabled={action.busy}>创建账号</button></form></Panel><Panel title="用户列表" action={<button onClick={users.refresh} disabled={users.loading}>刷新</button>}><ResourceState resource={users}/>{users.data?.length === 0 && <Empty>暂无用户</Empty>}{!!users.data?.length && <div className="table-scroll"><table className="accounts-table"><thead><tr><th>用户</th><th>角色</th><th>配额</th><th>创建时间</th><th>账号安全</th></tr></thead><tbody>{users.data.map(u => <tr key={u.id}><td data-label="用户"><strong>{u.username}</strong><small className="mono">{u.id}</small></td><td data-label="角色">{u.admin ? '管理员' : '成员'}</td><td data-label="配额"><div className="quota-cell"><span>{bytes(u.quota_bytes)}</span><button className="account-action" onClick={() => {setNotice(''); setQuotaUser(u);}}>设置配额</button></div></td><td data-label="创建时间">{date(u.created_at)}</td><td data-label="账号安全"><ResetPassword user={u} onReset={audit.refresh}/></td></tr>)}</tbody></table></div>}</Panel><Panel title="操作审计" action={<button onClick={audit.refresh} disabled={audit.loading}>刷新</button>}><ResourceState resource={audit}/>{audit.data?.length === 0 && <Empty>暂无审计记录</Empty>}{!!audit.data?.length && <div className="table-scroll"><table><thead><tr><th>时间</th><th>用户</th><th>操作</th><th>目标</th><th>详情</th></tr></thead><tbody>{audit.data.map(a => <tr key={a.id}><td>{date(a.created_at)}</td><td className="mono">{a.user_id}</td><td>{a.action}</td><td className="wrap">{a.target}</td><td className="wrap">{a.detail}</td></tr>)}</tbody></table></div>}</Panel></>;
function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const element = e.currentTarget, form = new FormData(element);
void action.run(async () => {
const password = String(form.get('password') || '');
const error = passwordResetError(password, password);
if (error) throw new Error(error);
const quota = quotaBytes(String(form.get('quota')));
if (quota === null) throw new Error('请输入有效配额');
await api.request('/users', 'POST', {username: form.get('username'), password: form.get('password'), admin: form.get('admin') === 'on', quota_bytes: quota});
element.reset();
await Promise.all([users.refresh(), audit.refresh()]);
}, '账号已创建');
}
return <>
{action.feedback}
{notice && <AlertBox variant="info" className="mb-4">{notice}</AlertBox>}
{quotaUser && <QuotaDialog user={quotaUser} onClose={() => setQuotaUser(null)} onSaved={() => { setNotice(quotaUser.username + ' 的配额已更新'); void users.refresh(); void audit.refresh(); }} />}
<Panel title="创建账号">
<form className="grid grid-cols-1 items-end gap-4 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_minmax(8rem,10rem)_auto]" onSubmit={create}>
<label className="grid gap-1.5 text-[13px] font-medium">用户名(3–40 位字母、数字、_ 或 -)
<input className={inputClass} name="username" required minLength={3} maxLength={40} pattern="[A-Za-z0-9_\-]{3,40}" disabled={action.busy} autoComplete="off" /></label>
<label className="grid gap-1.5 text-[13px] font-medium">初始密码(12–72 UTF-8 字节)
<input className={inputClass} name="password" type="password" disabled={action.busy} required autoComplete="new-password" /></label>
<label className="grid gap-1.5 text-[13px] font-medium">工作区配额(GiB)
<input className={inputClass} name="quota" disabled={action.busy} type="number" min="0.1" step="0.1" required defaultValue="10" /></label>
<label className="flex items-center gap-2 text-[13px] font-medium sm:col-span-2 lg:col-span-1 lg:row-start-2 lg:col-start-1 xl:col-auto xl:row-auto">
<input name="admin" disabled={action.busy} type="checkbox" className="size-4 accent-primary" />管理员(拥有平台管理权限)</label>
<button className={cn(buttonVariants(), 'sm:col-span-2 sm:justify-self-start lg:col-span-1 lg:col-start-4 lg:row-start-1')} disabled={action.busy}>创建账号</button>
</form>
</Panel>
<Panel title="用户列表" action={<RefreshButton loading={users.loading} onClick={users.refresh} />}>
<ResourceState resource={users} />
{users.data?.length === 0 && <Empty>暂无用户</Empty>}
{!!users.data?.length && <>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{users.data.map(u => <div key={u.id} className="rounded-lg border border-border bg-card p-3.5">
<div className="mb-1 flex items-center justify-between gap-2">
<strong className="truncate text-[14px]">{u.username}</strong>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">{u.admin ? '管理员' : '成员'}</span>
</div>
<p className="m-0 font-mono text-[11px] text-muted-foreground">{u.id}</p>
<p className="mb-3 mt-1 text-xs text-muted-foreground">创建于 {date(u.created_at)}</p>
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[13px]">配额 {bytes(u.quota_bytes)}</span>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} onClick={() => { setNotice(''); setQuotaUser(u); }}>设置配额</button>
<ResetPassword user={u} onReset={audit.refresh} />
</div>
</div>)}
</div>
</>}
</Panel>
<Panel title="操作审计" action={<RefreshButton loading={audit.loading} onClick={audit.refresh} />}>
<ResourceState resource={audit} />
{audit.data?.length === 0 && <Empty>暂无审计记录</Empty>}
{!!audit.data?.length && <>
<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)}>详情</th>
</tr></thead>
<tbody>{audit.data.map(a => <tr key={a.id} className={trClass}>
<td className="border-b border-border px-3 py-3 align-top whitespace-nowrap">{date(a.created_at)}</td>
<td className="border-b border-border px-3 py-3 align-top font-mono text-xs">{a.user_id}</td>
<td className={tdClass}>{a.action}</td>
<td className="max-w-52 border-b border-border px-3 py-3 align-top wrap-anywhere">{a.target}</td>
<td className="max-w-52 border-b border-border px-3 py-3 align-top wrap-anywhere">{a.detail}</td>
</tr>)}</tbody>
</table>
</div>
<div className="grid gap-2 md:hidden">
{audit.data.map(a => <div key={a.id} className="rounded-lg border border-border bg-card p-3 text-[13px]">
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{date(a.created_at)}</span><span className="font-mono">{a.user_id}</span>
</div>
<p className="m-0"><strong>{a.action}</strong> · <span className="wrap-anywhere">{a.target}</span></p>
{a.detail && <p className="m-0 mt-0.5 text-muted-foreground wrap-anywhere">{a.detail}</p>}
</div>)}
</div>
</>}
</Panel>
</>;
}
+175 -34
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Boxes, Container, FolderOpen, LogOut, Package, Settings, SquareTerminal, Users } from 'lucide-react';
import { api, ApiError, type User } from './api';
import { Alert, message, Panel, useAction } from './ui';
import { Instances } from './Instances';
@@ -8,52 +9,192 @@ import { Workspace } from './Workspace';
import { Admin } from './Admin';
import { ChangePassword } from './ChangePassword';
import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace';
const pages = [{id:'dsh',label:'DSH',icon:'◈',description:'你的 DeepSeek Harness 工作台'}, {id:'overview',label:'实例',icon:'◫',description:'管理你的 DeepSeek Harness 运行环境'}, {id:'workspace',label:'文件',icon:'▤',description:'管理当前实例的文件与目录'}, {id:'settings',label:'设置',icon:'⚙',description:'选择默认实例与管理工作台'}];
const secondaryPages = [{id:'images',label:'镜像与构建',description:'本地版本管理 · 手动构建 · 任务日志'}, {id:'admin',label:'用户与审计',description:'账号管理与操作追踪'}];
import { Button, buttonVariants } from './components/ui/button';
import { Input } from './components/ui/input';
import { cn } from './lib/utils';
const pages = [
{id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'},
{id: 'overview', label: '实例', icon: Container, description: '管理你的 DeepSeek Harness 运行环境'},
{id: 'workspace', label: '文件', icon: FolderOpen, description: '管理当前实例的文件与目录'},
{id: 'settings', label: '设置', icon: Settings, description: '选择默认实例与管理工作台'},
];
const secondaryPages = [
{id: 'images', label: '镜像与构建', icon: Package, description: '本地版本管理 · 手动构建 · 任务日志'},
{id: 'admin', label: '用户与审计', icon: Users, description: '账号管理与操作追踪'},
];
export default function App() {
const [user, setUser] = useState<User | null>(null), [loading, setLoading] = useState(true), [error, setError] = useState('');
useEffect(() => { let active = true; api.me().then(s => {if(active) setUser(s.user);}).catch(e => {if(active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e));}).finally(() => {if(active) setLoading(false);}); const expired = (event: Event) => {setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。');}; window.addEventListener('session-expired', expired); return () => {active = false; window.removeEventListener('session-expired', expired);}; }, []);
if (loading) return <div className='splash' role='status'>正在连接工作台…</div>;
if (!user) return <Login error={error} onLogin={u => {setUser(u); setError('');}} />;
return <Console key={user.id} user={user} onLogout={() => setUser(null)} onPasswordChanged={() => {setUser(null); setError('密码已修改,请使用新密码重新登录。');}}/>;
useEffect(() => {
let active = true;
api.me().then(s => { if (active) setUser(s.user); }).catch(e => { if (active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e)); }).finally(() => { if (active) setLoading(false); });
const expired = (event: Event) => { setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。'); };
window.addEventListener('session-expired', expired);
return () => { active = false; window.removeEventListener('session-expired', expired); };
}, []);
if (loading) return <div className="grid min-h-dvh place-items-center text-muted-foreground" role="status">正在连接工作台…</div>;
if (!user) return <Login error={error} onLogin={u => { setUser(u); setError(''); }} />;
return <Console key={user.id} user={user} onLogout={() => setUser(null)} onPasswordChanged={() => { setUser(null); setError('密码已修改,请使用新密码重新登录。'); }} />;
}
function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) {
const [page, setPage] = useState('dsh');
const logout = useAction();
const runtime = useRuntimeInstances(user.id, page);
const current = [...pages, ...secondaryPages].find(p => p.id === page)!;
const primaryPage = secondaryPages.some(p => p.id === page) ? 'settings' : page;
const exit = () => void logout.run(async () => {await api.logout(); onLogout();});
return <div className='shell console-shell'>
<aside className='sidebar'><div className='brand'><b>DSH<span>●</span></b><small>HARNESS CONSOLE</small></div><div className='nav-label'>工作台</div>
<nav aria-label='主导航'>{pages.map(p => <button key={p.id} className={primaryPage === p.id ? 'active' : ''} aria-current={primaryPage === p.id ? 'page' : undefined} onClick={() => setPage(p.id)}><span aria-hidden='true'>{p.icon}</span>{p.label}</button>)}</nav>
<div className='sidebar-bottom'><div className='avatar'>{user.username.slice(0,1).toUpperCase()}</div><div><strong>{user.username}</strong><small>{user.admin ? '管理员' : '成员'}</small></div><button title='退出登录' disabled={logout.busy} onClick={exit}>退出</button></div>
</aside>
<main><header className='topbar'><span>控制台 / {current.label}</span><span className='tag'>本地镜像 · 独立实例</span></header>
{logout.feedback}
<RuntimeWorkspace runtime={runtime} visible={page === 'dsh'} manage={() => setPage('overview')} settings={() => setPage('settings')}/>
{page !== 'dsh' && <div className='panel-scroll'><div className='content'><div className='page-heading'><div><p className='eyebrow'>DEEPSEEK HARNESS</p><h1>{current.label}</h1><p className='muted'>{current.description}</p></div></div>
{page === 'overview' && <Instances />}
{page === 'images' && <><button className='back-settings' onClick={() => setPage('settings')}>← 返回设置</button><Images admin={user.admin}/></>}
{page === 'workspace' && <Workspace runtime={runtime}/>}
{page === 'admin' && user.admin && <><button className='back-settings' onClick={() => setPage('settings')}>← 返回设置</button><Admin /></>}
{page === 'settings' && <>
<Panel title='DSH 实例' action={<button disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</button>}>
{runtime.error && <Alert>{runtime.error}<button disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</button></Alert>}
{runtime.loading && <p role='status' className='muted'>正在刷新实例…</p>}
<RuntimeSelector runtime={runtime}/>
<p className='muted selection-note'>仅在此浏览器按账号记住实例 ID,不保存密钥。已选择的停止实例不会自动切换。</p>
<div className='actions'><button className='primary' onClick={() => setPage('dsh')}>进入 DSH</button><button onClick={() => setPage('overview')}>管理 / 创建实例</button></div>
</Panel>
<Panel title='工作台管理'><div className='actions'><button onClick={() => setPage('images')}>镜像与构建</button>{user.admin && <button onClick={() => setPage('admin')}>用户与审计</button>}</div></Panel>
<Panel title='账号'><p>{user.username} · {user.admin ? '管理员' : '成员'}</p><p className='muted'>修改密码会使所有设备退出登录。</p><ChangePassword onChanged={onPasswordChanged}/><div className='account-signout'><p className='muted'>仅退出当前浏览器会话,不影响运行中的实例。</p><button className='danger account-action' disabled={logout.busy} onClick={exit}>{logout.busy ? '正在退出…' : '退出登录'}</button></div></Panel>
// Secondary pages live under 设置 on the mobile bottom nav.
const navPage = secondaryPages.some(p => p.id === page) ? 'settings' : page;
const exit = () => void logout.run(async () => { await api.logout(); onLogout(); });
const navButton = (p: typeof pages[number], mobile: boolean) => {
const Icon = p.icon;
// Desktop sidebar lists secondary pages directly, so highlight by the real
// page; the mobile bottom bar only has the four primary entries.
const active = (mobile ? navPage : page) === p.id;
return <button key={p.id} aria-current={active ? 'page' : undefined} onClick={() => setPage(p.id)}
className={mobile
? cn('flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 py-1 text-[12px] transition-colors', active ? 'text-nav-accent' : 'text-nav-foreground hover:text-white')
: cn('flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-[13.5px] transition-colors focus-visible:outline-2 focus-visible:outline-ring', active ? 'bg-nav-active text-nav-accent font-medium shadow-[inset_3px_0_0_0_var(--color-nav-accent)]' : 'text-nav-foreground hover:bg-nav-active/60 hover:text-white')}>
<Icon className={mobile ? 'size-5' : 'size-4 shrink-0'} aria-hidden />
<span className="truncate">{p.label}</span>
</button>;
};
return <div className="flex h-dvh overflow-hidden bg-background">
{/* Desktop sidebar */}
<aside className="hidden w-60 shrink-0 flex-col bg-nav text-nav-foreground md:flex">
<div className="px-5 pt-7 pb-8">
<b className="block text-[26px] font-bold leading-tight tracking-widest text-white">DSH<span className="ml-1 align-middle text-[10px] text-nav-accent">●</span></b>
<small className="mt-1 block text-[9px] tracking-[0.2em] text-nav-muted">HARNESS CONSOLE</small>
</div>
<nav aria-label="主导航" className="grid gap-1 px-3">
<p className="m-0 px-2 pb-1 text-[11px] text-nav-muted">工作台</p>
{pages.map(p => navButton(p, false))}
{user.admin && <>
<p className="m-0 mt-4 px-2 pb-1 text-[11px] text-nav-muted">管理</p>
{secondaryPages.map(p => {
const Icon = p.icon;
const active = page === p.id;
return <button key={p.id} aria-current={active ? 'page' : undefined} onClick={() => setPage(p.id)}
className={cn('flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-[13.5px] transition-colors focus-visible:outline-2 focus-visible:outline-ring', active ? 'bg-nav-active text-nav-accent font-medium shadow-[inset_3px_0_0_0_var(--color-nav-accent)]' : 'text-nav-foreground hover:bg-nav-active/60 hover:text-white')}>
<Icon className="size-4 shrink-0" aria-hidden />
<span className="truncate">{p.label}</span>
</button>;
})}
</>}
</div><footer>DSH 工作台 <span>数据与操作以服务器实际结果为准</span></footer></div>}
</nav>
<div className="mt-auto flex items-center gap-2.5 border-t border-white/10 px-5 py-4">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-nav-active text-[13px] font-semibold text-nav-accent">{user.username.slice(0, 1).toUpperCase()}</div>
<div className="min-w-0 flex-1 leading-tight">
<strong className="block truncate text-[13px] text-white">{user.username}</strong>
<small className="text-[11px] text-nav-muted">{user.admin ? '管理员' : '成员'}</small>
</div>
<button title="退出登录" aria-label="退出登录" disabled={logout.busy} onClick={exit} className="rounded-md p-2 text-nav-muted transition-colors hover:bg-nav-active hover:text-white">
<LogOut className="size-4" aria-hidden />
</button>
</div>
</aside>
{/* Mobile bottom navigation */}
<nav aria-label="主导航" className="fixed inset-x-0 bottom-0 z-20 flex bg-nav pb-[env(safe-area-inset-bottom,0px)] md:hidden">
{pages.map(p => navButton(p, true))}
</nav>
<main className="flex min-w-0 flex-1 flex-col pb-[calc(60px+env(safe-area-inset-bottom,0px))] md:pb-0">
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-4 sm:px-6">
<span className="truncate text-[13px] text-muted-foreground">控制台 <span aria-hidden>/</span> <span className="font-medium text-foreground">{current.label}</span></span>
<span className="hidden rounded-md border border-border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground sm:inline">本地镜像 · 独立实例</span>
</header>
{logout.feedback}
<RuntimeWorkspace runtime={runtime} visible={page === 'dsh'} manage={() => setPage('overview')} settings={() => setPage('settings')} />
{page !== 'dsh' && <div className="min-h-0 flex-1 overflow-auto overscroll-contain">
<div className="mx-auto w-full max-w-6xl p-4 sm:p-6">
<div className="mb-5">
<p className="m-0 mb-1 text-[10px] font-bold tracking-[0.22em] text-primary">DEEPSEEK HARNESS</p>
<h1 className="mb-1 text-[22px] font-semibold leading-snug tracking-tight">{current.label}</h1>
<p className="m-0 text-[13px] text-muted-foreground">{current.description}</p>
</div>
{page === 'overview' && <Instances />}
{(page === 'images' || page === 'admin') && <button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}), 'mb-4 -ml-2 text-muted-foreground md:hidden')} onClick={() => setPage('settings')}>← 返回设置</button>}
{page === 'images' && <Images admin={user.admin} />}
{page === 'workspace' && <Workspace runtime={runtime} />}
{page === 'admin' && user.admin && <Admin />}
{page === 'settings' && <>
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
<RuntimeSelector runtime={runtime} />
<p className="mb-4 mt-3 text-[13px] text-muted-foreground">仅在此浏览器按账号记住实例 ID,不保存密钥。已选择的停止实例不会自动切换。</p>
<div className="flex flex-wrap gap-2">
<Button onClick={() => setPage('dsh')}>进入 DSH</Button>
<Button variant="outline" onClick={() => setPage('overview')}>管理 / 创建实例</Button>
</div>
</Panel>
<Panel title="工作台管理">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setPage('images')}><Package aria-hidden />镜像与构建</Button>
{user.admin && <Button variant="outline" onClick={() => setPage('admin')}><Users aria-hidden />用户与审计</Button>}
</div>
</Panel>
<Panel title="账号">
<p className="mb-1 text-[13px]">{user.username} · {user.admin ? '管理员' : '成员'}</p>
<p className="mb-4 text-[13px] text-muted-foreground">修改密码会使所有设备退出登录。</p>
<ChangePassword onChanged={onPasswordChanged} />
<div className="mt-5 border-t border-border pt-4">
<p className="mb-3 text-[13px] text-muted-foreground">仅退出当前浏览器会话,不影响运行中的实例。</p>
<Button variant="destructive-ghost" disabled={logout.busy} onClick={exit}><LogOut aria-hidden />{logout.busy ? '正在退出…' : '退出登录'}</Button>
</div>
</Panel>
</>}
<footer className="mt-8 hidden items-center justify-between border-t border-border pt-4 text-[11px] text-muted-foreground md:flex">
<span>DSH 工作台</span><span>数据与操作以服务器实际结果为准</span>
</footer>
</div>
</div>}
</main>
</div>;
}
function Login({error, onLogin}: {error: string; onLogin: (user: User) => void}) {
const action = useAction();
function submit(e: FormEvent<HTMLFormElement>) {e.preventDefault(); const form = new FormData(e.currentTarget); void action.run(async () => {const session = await api.login(String(form.get('username')), String(form.get('password'))); onLogin(session.user);});}
return <div className="login"><section className="login-intro"><div className="brand"><b>DSH<span>●</span></b><small>HARNESS CONSOLE</small></div><div><p className="eyebrow">你的 AI 开发空间</p><h1>专注创造,<br/>让环境管理更简单。</h1><p>独立实例、本地镜像、实例数据根。<br/>在一个工作台中,掌控你的 DeepSeek Harness。</p></div><small>安全会话 · 资源隔离 · 操作审计</small></section><section className="login-form"><form onSubmit={submit}><p className="eyebrow">WELCOME BACK</p><h2>登录工作台</h2><p className="muted">使用管理员分配的账号继续</p>{error && <Alert>{error}</Alert>}{action.feedback}<label>用户名<input name="username" autoComplete="username" required autoFocus /></label><label>密码<input name="password" type="password" autoComplete="current-password" required /></label><button className="primary" disabled={action.busy}>{action.busy ? '正在登录…' : '登录 →'}</button><p className="login-note">没有账号?请联系平台管理员。<br/>登录凭据不会存储在浏览器本地存储中。</p></form></section></div>;
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
void action.run(async () => {
const session = await api.login(String(form.get('username')), String(form.get('password')));
onLogin(session.user);
});
}
return <div className="grid min-h-dvh bg-card md:grid-cols-2">
<section className="flex flex-col justify-between gap-10 bg-nav p-8 text-nav-foreground sm:p-12">
<div>
<b className="block text-[26px] font-bold leading-tight tracking-widest text-white">DSH<span className="ml-1 align-middle text-[10px] text-nav-accent">●</span></b>
<small className="mt-1 block text-[9px] tracking-[0.2em] text-nav-muted">HARNESS CONSOLE</small>
</div>
<div className="hidden md:block">
<p className="m-0 mb-3 text-[10px] font-bold tracking-[0.22em] text-nav-accent">你的 AI 开发空间</p>
<h1 className="mb-5 text-[34px] font-semibold leading-[1.5] text-white">专注创造,<br />让环境管理更简单。</h1>
<p className="m-0 leading-loose text-nav-muted">独立实例、本地镜像、实例数据根。<br />在一个工作台中,掌控你的 DeepSeek Harness。</p>
</div>
<small className="hidden text-[12px] text-nav-muted md:block">安全会话 · 资源隔离 · 操作审计</small>
</section>
<section className="grid place-items-center p-6 sm:p-10">
<form onSubmit={submit} className="w-full max-w-sm">
<p className="m-0 mb-2 text-[10px] font-bold tracking-[0.22em] text-primary">WELCOME BACK</p>
<h2 className="mb-1 text-[24px] font-semibold">登录工作台</h2>
<p className="mb-6 text-[13px] text-muted-foreground">使用管理员分配的账号继续</p>
<div className="grid gap-3">
{error && <Alert>{error}</Alert>}
{action.feedback}
</div>
<div className="mt-4 grid gap-4">
<label className="grid gap-1.5 text-[13px] font-medium">用户名<Input name="username" autoComplete="username" required autoFocus /></label>
<label className="grid gap-1.5 text-[13px] font-medium">密码<Input name="password" type="password" autoComplete="current-password" required /></label>
</div>
<Button size="lg" className="mt-6 w-full" disabled={action.busy}>{action.busy ? '正在登录…' : '登录 →'}</Button>
<p className="mb-0 mt-5 text-[12px] leading-relaxed text-muted-foreground">没有账号?请联系平台管理员。<br />登录凭据不会存储在浏览器本地存储中。</p>
</form>
</section>
</div>;
}
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
useRef(value: unknown) {const i = state.cursor++; return state.slots[i] ??= {current: value};},
useEffect() {},
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: () => ({data: [{id: 'backup', created_at: '', size: 1}], loading: false, refresh: vi.fn(async () => {})}),
}));
import { BackupPanel } from './BackupPanel';
import { api } from './api';
import { ConfirmDialog } from './components/ConfirmDialog';
import { DialogContent } from './components/ui/dialog';
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
const refresh = vi.fn(async () => {});
function render() {state.cursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh});}
function editor() {
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
return child.type(child.props);
}
beforeEach(() => {state.slots = []; state.cursor = 0; vi.restoreAllMocks(); refresh.mockClear();});
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
nodes(editor(), n => n.type === 'input')[0].props.onChange({target: {value: 'original-password'}});
nodes(editor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
const modal = nodes(editor(), n => n.type === DialogContent)[0];
expect(JSON.stringify(modal)).toContain('restore denied');
expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password');
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'});
expect(refresh).not.toHaveBeenCalled();
});
+102 -16
View File
@@ -1,26 +1,112 @@
import { useState } from 'react';
import { useState, type FormEvent, type ReactNode } from 'react';
import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react';
import { api, instancePath, type Instance, type Backup } from './api';
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { AlertBox } from './components/ui/alert';
import { DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog';
import { buttonVariants } from './components/ui/button';
import { inputClass } from './components/ui/input';
import { cn } from './lib/utils';
export function BackupPanel({ instance, refresh }: { instance: Instance; refresh: () => Promise<void> }) {
export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise<void>}) {
const base = instancePath(instance.id);
const backups = useResource(() => api.request<Backup[]>(base + '/backups'));
const action = useAction();
const [password, setPassword] = useState(''), [originalPassword, setOriginalPassword] = useState(''), [file, setFile] = useState<File>();
return <section aria-label="备份与恢复">
<p className="callout">操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。</p>
{action.feedback}
<form onSubmit={e => { e.preventDefault(); void action.run(async () => { try { await api.request(base + '/backups', 'POST', { password }); await backups.refresh(); } finally { setPassword(''); } }, '加密备份已创建'); }}>
<label>当前登录密码<input type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} /></label>
<button disabled={action.busy || !password}>创建加密备份</button>
</form>
<label>原备份密码(旧平台密钥包留空)<input type="password" autoComplete="off" value={originalPassword} onChange={e => setOriginalPassword(e.target.value)} /></label>
<form onSubmit={e => { e.preventDefault(); if (!file) return; void action.run(async () => { try { if (file.size > 44743364) throw new Error('备份包超过大小限制'); const form = new FormData(); form.set('file', file); form.set('password', originalPassword); await api.request(base + '/backups', 'POST', form); await backups.refresh(); } finally { setOriginalPassword(''); } }, '备份已验证并导入;尚未覆盖当前配置'); }}>
<label>加密备份文件<input type="file" accept=".dshbak" onChange={e => setFile(e.target.files?.[0])} /></label>
<button disabled={action.busy || !file}>验证并导入</button>
</form>
const confirm = useConfirm();
const [password, setPassword] = useState('');
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
const [restoreTarget, setRestoreTarget] = useState<Backup>();
function createBackup(e: FormEvent) {
e.preventDefault();
void action.run(async () => {
try { await api.request(base + '/backups', 'POST', {password}); await backups.refresh(); }
finally { setPassword(''); }
}, '加密备份已创建');
}
function importBackup(e: FormEvent) {
e.preventDefault();
if (!file) return;
void action.run(async () => {
try {
if (file.size > 44743364) throw new Error('备份包超过大小限制');
const form = new FormData();
form.set('file', file);
form.set('password', importPassword);
await api.request(base + '/backups', 'POST', form);
await backups.refresh();
} finally { setImportPassword(''); }
}, '备份已验证并导入;尚未覆盖当前配置');
}
async function restore(backup: Backup, originalPassword: string) {
if (!await confirm.ask({
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
})) return false;
return action.run(async () => { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await refresh(); }, '配置恢复及插件验证完成,已恢复原运行状态。');
}
return <section aria-label="备份与恢复" className="grid gap-5">
{confirm.element}
<AlertBox variant="info">操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。</AlertBox>
<div className="grid gap-5 lg:grid-cols-2">
<form onSubmit={createBackup} className="grid content-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
<h3 className="m-0 flex items-center gap-1.5 text-[14px] font-semibold"><HardDriveDownload className="size-4 text-primary" aria-hidden />创建加密备份</h3>
<label className="grid gap-1.5 text-[13px] font-medium">当前登录密码
<input className={inputClass} type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} /></label>
<div><button className={cn(buttonVariants({size: 'sm'}))} disabled={action.busy || !password}>创建加密备份</button></div>
</form>
<form onSubmit={importBackup} className="grid content-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
<h3 className="m-0 flex items-center gap-1.5 text-[14px] font-semibold"><Upload className="size-4 text-primary" aria-hidden />导入外部备份</h3>
<label className="grid gap-1.5 text-[13px] font-medium">加密备份文件
<input type="file" accept=".dshbak" onChange={e => setFile(e.target.files?.[0])}
className="w-full cursor-pointer rounded-md border border-input bg-card px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-secondary file:px-2.5 file:py-1 file:text-xs file:text-secondary-foreground" /></label>
<label className="grid gap-1.5 text-[13px] font-medium">原备份密码(旧平台密钥包留空)
<input className={inputClass} type="password" autoComplete="off" value={importPassword} onChange={e => setImportPassword(e.target.value)} /></label>
<div><button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy || !file}>验证并导入</button></div>
</form>
</div>
{!restoreTarget && action.feedback}
<ResourceState resource={backups} />
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
{backups.data?.map(b => <div className="backup-row" key={b.id}><span>{b.id}</span><span>{date(b.created_at)}</span><span>{bytes(b.size)}</span><a className="button" href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}>下载</a><button disabled={action.busy} onClick={() => action.run(async () => { try { await api.request(base + '/restore', 'POST', { backup_id: b.id, password: originalPassword }); await refresh(); } finally { setOriginalPassword(''); } }, '配置恢复及插件验证完成,已恢复原运行状态。', '确认覆盖当前配置?服务器将先保留当前 home 快照。')}>恢复</button></div>)}
<div className="grid gap-2">
{backups.data?.map(b => <div key={b.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-border bg-card px-3.5 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
</div>
<div className="flex gap-1.5">
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
</div>
</div>)}
</div>
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
const ok = await restore(restoreTarget, password);
if (ok) setRestoreTarget(undefined);
}} />}
</section>;
}
function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) {
const [password, setPassword] = useState('');
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent title={'恢复备份 · ' + backup.id}>
<form className="grid gap-4" onSubmit={e => { e.preventDefault(); void onSubmit(password); }}>
{feedback}
<AlertBox variant="info">创建于 {date(backup.created_at)} · {bytes(backup.size)}。输入创建该备份时使用的登录密码;旧平台密钥包可留空。</AlertBox>
<label className="grid gap-1.5 text-[13px] font-medium">原备份密码
<input className={inputClass} type="password" autoComplete="off" value={password} onChange={e => setPassword(e.target.value)} disabled={busy} autoFocus /></label>
<DialogFooter className="border-t-0 pt-0">
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={busy} onClick={onClose}>取消</button>
<button className={cn(buttonVariants({variant: 'destructive'}))} disabled={busy}>{busy ? '正在恢复…' : '恢复此备份'}</button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>;
}
+10 -1
View File
@@ -1,8 +1,17 @@
import { useState } from 'react';
import { KeyRound } from 'lucide-react';
import { api } from './api';
import { PasswordDialog } from './PasswordDialog';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function ChangePassword({onChanged}: {onChanged: () => void}) {
const [open, setOpen] = useState(false);
return <><button className="account-action" onClick={() => setOpen(true)}>修改密码</button>{open && <PasswordDialog title="修改密码" currentRequired onClose={() => setOpen(false)} onSubmit={async (password, current) => {await api.request('/me/password', 'POST', {current_password:current, password}); onChanged();}}/>}</>;
return <>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} onClick={() => setOpen(true)}><KeyRound aria-hidden />修改密码</button>
{open && <PasswordDialog title="修改密码" currentRequired onClose={() => setOpen(false)} onSubmit={async (password, current) => {
await api.request('/me/password', 'POST', {current_password: current, password});
onChanged();
}} />}
</>;
}
+26
View File
@@ -0,0 +1,26 @@
import { beforeEach, expect, it, vi } from 'vitest';
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, cleanup: undefined as undefined | (() => void)}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) { const i = hooks.cursor++; return [hooks.slots[i] ?? value, (next: unknown) => { hooks.slots[i] = next; }]; },
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current: value}; },
useEffect(effect: () => () => void) { hooks.cleanup = effect(); },
}));
import { useConfirm } from './components/ConfirmDialog';
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.cleanup = undefined; });
it('cancels a superseded confirmation and only accepts the current request', async () => {
const confirm = useConfirm();
const first = confirm.ask({title: 'first'});
const second = confirm.ask({title: 'second'});
expect(await first).toBe(false);
confirm.element.props.onClose(true);
expect(await second).toBe(true);
});
it('unmount cancels a pending action instead of leaving it suspended', async () => {
const confirm = useConfirm();
const pending = confirm.ask({title: 'delete'});
hooks.cleanup?.();
expect(await pending).toBe(false);
confirm.element.props.onClose(true);
expect(await pending).toBe(false);
});
+27 -10
View File
@@ -1,12 +1,16 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { api, selectableImage, type Instance } from './api';
import { ResourceState, useAction, useResource } from './ui';
import { AlertBox } from './components/ui/alert';
import { buttonVariants } from './components/ui/button';
import { inputClass, selectClass, selectStyle } from './components/ui/input';
import { cn } from './lib/utils';
export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise<void>}) {
const images = useResource(api.images), action = useAction();
const [progress, setProgress] = useState('');
const active = useRef(true);
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
const available = images.data?.filter(selectableImage) || [];
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -16,18 +20,31 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
if (!active.current) return;
if (!image) throw new Error('请选择可用的本地镜像。');
setProgress('正在创建实例,请勿重复提交…');
const created = await api.request<Instance>('/instances', 'POST', {name, image_id:image.image_id || image.id});
const created = await api.request<Instance>('/instances', 'POST', {name, image_id: image.image_id || image.id});
if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。');
if (active.current) {await refresh(); close();}
if (active.current) { await refresh(); close(); }
});
if (active.current) setProgress('');
}
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}
<p className="callout">请选择已验证并准入的本地镜像。创建使用所选镜像的精确 ID,不会自动构建或切换版本。</p>
<fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label>
<ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><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>
{!images.loading && !available.length && <p>暂无已准入的可用镜像。请联系管理员构建并验证发布镜像。</p>}
<button className="primary" disabled={!available.length}>确认创建</button>
</fieldset>{progress && <p role="status">{progress}</p>}<button type="button" disabled={action.busy} onClick={close}>关闭</button>
return <form className="mb-5 grid gap-4 rounded-lg border border-border bg-muted/50 p-4" onSubmit={submit}>
<h3 className="m-0 text-[14px] font-semibold">创建独立实例</h3>
{action.feedback}
<AlertBox variant="info">请选择已验证并准入的本地镜像。创建使用所选镜像的精确 ID,不会自动构建或切换版本。</AlertBox>
<fieldset disabled={action.busy} className="m-0 grid min-w-0 gap-4 border-0 p-0">
<label className="grid gap-1.5 text-[13px] font-medium">实例名称<input className={inputClass} name="name" required maxLength={64} /></label>
<ResourceState resource={images} />
<label className="grid gap-1.5 text-[13px] font-medium">本地镜像
<select className={selectClass} style={selectStyle} name="image" required defaultValue="">
<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>
{!images.loading && !available.length && <p className="m-0 text-[13px] text-muted-foreground">暂无已准入的可用镜像。请联系管理员构建并验证发布镜像。</p>}
<div className="flex flex-wrap gap-2">
<button className={cn(buttonVariants())} disabled={!available.length}>确认创建</button>
<button type="button" className={cn(buttonVariants({variant: 'ghost'}))} disabled={action.busy} onClick={close}>关闭</button>
</div>
</fieldset>
{progress && <p role="status" className="m-0 text-[13px] text-muted-foreground">{progress}</p>}
</form>;
}
+12 -11
View File
@@ -1,14 +1,15 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import type { ReactNode } from 'react';
import { DialogContent, DialogRoot } from './components/ui/dialog';
export function Dialog({title, busy, onClose, children}: {title: string; busy: boolean; onClose: () => void; children: ReactNode}) {
const ref = useRef<HTMLDialogElement>(null), titleId = useId();
useEffect(() => {
const trigger = document.activeElement as HTMLElement | null, dialog = ref.current!;
dialog.showModal();
dialog.querySelector<HTMLInputElement>('input')?.focus();
return () => {dialog.close(); if (trigger?.isConnected) trigger.focus();};
}, []);
return <dialog ref={ref} className="account-dialog" aria-labelledby={titleId} aria-busy={busy} onCancel={event => {event.preventDefault(); if (!busy) onClose();}}>
<div className="dialog-heading"><h2 id={titleId}>{title}</h2><button type="button" aria-label="关闭对话框" disabled={busy} onClick={onClose}>关闭</button></div>{children}
</dialog>;
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent title={title} aria-busy={busy} onOpenAutoFocus={e => {
// Focus the first input when the dialog opens, mirroring the old native dialog.
e.preventDefault();
const content = e.currentTarget as HTMLElement;
content.querySelector<HTMLInputElement>('input')?.focus();
}}>
{children}
</DialogContent>
</DialogRoot>;
}
+36 -10
View File
@@ -16,13 +16,18 @@ import { ReleasePanel } from './ReleasePanel';
import { CreateInstance } from './CreateInstance';
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
import { ConfirmDialog } from './components/ConfirmDialog';
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
function render(admin = false) {state.cursor = 0; return Images({admin});}
beforeEach(() => {state.slots = []; state.cursor = 0; state.emptyImages = false; vi.stubGlobal('window', {confirm:vi.fn(() => false)});});
// In-app confirm dialog replaced window.confirm: resolve it via onClose.
const dialog = (root: any) => nodes(root, n => n.type === ConfirmDialog)[0];
const approve = (root: any) => dialog(root).props.onClose(true);
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
beforeEach(() => {state.slots = []; state.cursor = 0; state.emptyImages = false; vi.stubGlobal('window', {});});
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
it('hides experimental and admission controls from ordinary users', () => {
const tree = render();
@@ -30,14 +35,23 @@ it('hides experimental and admission controls from ordinary users', () => {
expect(nodes(render(true), n => n.type === ReleasePanel)).toHaveLength(1);
expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
// Each image row renders twice: once in the desktop table, once as a mobile card.
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(4);
});
it.each([false, true])('has no legacy build controls (admin=%s)', admin => {
const tree = render(admin);
expect(nodes(tree, n => n.type === 'form' || n.type === 'input')).toHaveLength(0);
expect(JSON.stringify(tree)).not.toMatch(/查询 latest|精确 DSH 版本|提交实验构建/);
});
it('requires evidence and confirmation; cancel sends no admission mutation', () => {
it('mounts only one selected admission editor outside responsive branches', () => {
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const tree = render(true);
expect(nodes(tree, n => typeof n.type === 'function' && n.props.imageId)).toHaveLength(1);
for (const branch of nodes(tree, n => typeof n.props?.className === 'string' && /md:hidden|md:block/.test(n.props.className))) {
expect(nodes(branch, n => typeof n.type === 'function' && n.props.imageId)).toHaveLength(0);
}
});
it('requires evidence and confirmation; cancel sends no admission mutation', async () => {
const request = vi.spyOn(api, 'admitImage');
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
@@ -48,7 +62,13 @@ it('requires evidence and confirmation; cancel sends no admission mutation', ()
nodes(form, n => n.type === 'input')[0].props.onChange({target:{checked:true}});
state.cursor = 0; form = child.type(child.props);
form.props.onSubmit({preventDefault:vi.fn()});
expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled();
state.cursor = 0; form = child.type(child.props);
// The in-app confirm dialog opens; cancelling it sends no mutation.
expect(dialog(form).props.request.options.title).toBe('更新准入策略');
expect(request).not.toHaveBeenCalled();
dialog(form).props.onClose(false);
await flush();
expect(request).not.toHaveBeenCalled();
});
it('creates with only name and exact image ID, leaving version resolution to the server', async () => {
const request = vi.spyOn(api, 'request').mockResolvedValue({id:'created'});
@@ -88,9 +108,9 @@ it('admission rejects blank/unconfirmed evidence, resets consent on edits, and s
nodes(renderAdmission(), n => n.type === 'select')[0].props.onChange({target:{value:'recommended'}});
expect(nodes(renderAdmission(), n => n.type === 'input')[0].props.checked).toBe(false);
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
vi.mocked(window.confirm).mockReturnValue(true);
submit(); submit();
await Promise.resolve();
approve(renderAdmission());
await flush();
expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'recommended', '验收记录 #42');
});
it.each([
@@ -105,7 +125,6 @@ it.each([
[' ' + 'é'.repeat(2048) + '\n', 4096, true],
] as const)('checks UTF-8 evidence bytes (case %#)', async (evidence, bytes, valid) => {
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
vi.mocked(window.confirm).mockReturnValue(true);
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = [];
@@ -118,9 +137,16 @@ it.each([
expect(nodes(form, n => n.type === 'p' && Array.isArray(n.props.children) && n.props.children.includes(bytes))).toHaveLength(1);
expect(nodes(form, n => n.type === 'button')[0].props.disabled).toBe(!valid);
form.props.onSubmit({preventDefault:vi.fn()});
await Promise.resolve();
if (valid) expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'compatible', evidence.trim());
else {expect(request).not.toHaveBeenCalled(); expect(window.confirm).not.toHaveBeenCalled();}
if (valid) {
// Valid evidence opens the in-app confirm dialog; approving submits once.
approve(renderAdmission());
await flush();
expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'compatible', evidence.trim());
} else {
// Invalid evidence never reaches the confirmation step.
expect(dialog(renderAdmission()).props.request).toBeUndefined();
expect(request).not.toHaveBeenCalled();
}
});
it('upgrade selector excludes experimental and unadmitted images', () => {
state.cursor = 0; Instances();
+185 -89
View File
@@ -1,107 +1,203 @@
import { useState } from 'react';
import { api, type AdmissionState, type Build } from './api';
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { api, type AdmissionState, type Build, type Image } from './api';
import { Panel, ResourceState, Empty, Status, date, useAction, useResource, RefreshButton, tableClass, tableHeadRowClass, thClass, tdClass, trClass } from './ui';
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
import { useConfirm } from './components/ConfirmDialog';
import { AlertBox } from './components/ui/alert';
import { buttonVariants } from './components/ui/button';
import { selectClass, selectStyle, textareaClass } from './components/ui/input';
import { cn } from './lib/utils';
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
const admissionLabels: Record<string, string> = {recommended: '推荐', compatible: '兼容', existing_only: '仅存量', blocked: '禁止使用'};
function BuildLogs({id, close}: {id: string; close: () => void}) {
const logs = useResource(() => api.build(id), id, 5000, activeBuild);
return <div>
<h3>构建日志</h3>
<button onClick={close}>关闭日志</button>
<ResourceState resource={logs}/>{logs.data && <>
<p>{logs.data.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}{logs.data.version} · <Status value={logs.data.status}/>
</p><BuildProvenance build={logs.data}/>{logs.data.error && <p className="alert">{logs.data.error}</p>}<pre className="logs">{logs.data.logs || '任务尚未输出日志'}</pre>
</>}</div>;
return <div className="mt-4 rounded-lg border border-border bg-muted/40 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="m-0 text-[14px] font-semibold">构建日志</h3>
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}))} onClick={close}>关闭日志</button>
</div>
<ResourceState resource={logs} />
{logs.data && <>
<p className="mt-0 flex flex-wrap items-center gap-2 text-[13px]">
{logs.data.experimental && <AlertBox variant="destructive" hideIcon className="inline-flex px-2 py-0.5 text-xs font-semibold">实验构建 · 不自动准入</AlertBox>}
{logs.data.version} · <Status value={logs.data.status} />
</p>
<BuildProvenance build={logs.data} />
{logs.data.error && <AlertBox variant="destructive" className="my-3">{logs.data.error}</AlertBox>}
<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>
</>}
</div>;
}
function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<void>}) {
function Admission({imageId, refresh}: {imageId: string; refresh: () => Promise<void>}) {
const [state, setState] = useState<AdmissionState>('compatible'), [evidence, setEvidence] = useState(''), [confirmed, setConfirmed] = useState(false);
const action = useAction();
const confirm = useConfirm();
const submittedEvidence = evidence.trim(), evidenceBytes = new TextEncoder().encode(submittedEvidence).length;
const evidenceInvalid = evidenceBytes === 0 || evidenceBytes > 4096;
return <form onSubmit={e => {e.preventDefault(); if (!confirmed || evidenceInvalid) return; void action.run(async () => {await api.admitImage(imageId, state, submittedEvidence); setEvidence(''); setConfirmed(false); await refresh();}, '镜像准入策略已更新', '确认依据实际验收结果修改此镜像准入策略?');}}>{action.feedback}<label>准入策略<select value={state} onChange={e => {setState(e.target.value as AdmissionState); setConfirmed(false);}}>
<option value="recommended">推荐</option>
<option value="compatible">兼容</option>
<option value="existing_only">仅存量</option>
<option value="blocked">禁止使用</option>
return <form className="mt-3 grid gap-3 rounded-lg border border-border bg-muted/40 p-4" onSubmit={async e => {
e.preventDefault();
if (!confirmed || evidenceInvalid) return;
if (!await confirm.ask({title: '更新准入策略', confirmLabel: '确认更新', body: '确认依据实际验收结果修改此镜像准入策略?'})) return;
void action.run(async () => { await api.admitImage(imageId, state, submittedEvidence); setEvidence(''); setConfirmed(false); await refresh(); }, '镜像准入策略已更新');
}}>
{confirm.element}
{action.feedback}
<label className="grid gap-1.5 text-[13px] font-medium">准入策略
<select className={selectClass} style={selectStyle} value={state} onChange={e => { setState(e.target.value as AdmissionState); setConfirmed(false); }}>
<option value="recommended">推荐</option>
<option value="compatible">兼容</option>
<option value="existing_only">仅存量</option>
<option value="blocked">禁止使用</option>
</select>
</label>
<p className="callout">构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。创建或升级须选择已准入的本地镜像 ID。</p>
<label>验收依据<textarea required aria-invalid={evidenceBytes > 4096} value={evidence} onChange={e => {setEvidence(e.target.value); setConfirmed(false);}}/>
</label>
<p className={evidenceBytes > 4096 ? 'alert' : 'muted'} role={evidenceBytes > 4096 ? 'alert' : undefined}>验收依据:{evidenceBytes} / 4096 UTF-8 字节(去除首尾空白后);中文通常占 3 字节,表情通常占 4 字节。{evidenceBytes > 4096 && '已超出字节上限,请缩短依据。'}</p>
<label>
<input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)}/>我已核验依据;禁止使用还会阻止启动/重启,但不自动停机</label>
<button disabled={action.busy || !confirmed || evidenceInvalid}>确认更新准入</button>
</form>;
</label>
<AlertBox variant="info">构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。创建或升级须选择已准入的本地镜像 ID。</AlertBox>
<label className="grid gap-1.5 text-[13px] font-medium">验收依据
<textarea className={textareaClass} required aria-invalid={evidenceBytes > 4096} value={evidence} onChange={e => { setEvidence(e.target.value); setConfirmed(false); }} />
</label>
<p className={cn('m-0 text-xs', evidenceBytes > 4096 ? 'font-medium text-destructive' : 'text-muted-foreground')} role={evidenceBytes > 4096 ? 'alert' : undefined}>
验收依据:{evidenceBytes} / 4096 UTF-8 字节(去除首尾空白后);中文通常占 3 字节,表情通常占 4 字节。{evidenceBytes > 4096 && '已超出字节上限,请缩短依据。'}
</p>
<label className="flex items-center gap-2 text-[13px] font-medium">
<input type="checkbox" className="size-4 accent-primary" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />
我已核验依据;禁止使用还会阻止启动/重启,但不自动停机
</label>
<div><button className={cn(buttonVariants({size: 'sm'}))} disabled={action.busy || !confirmed || evidenceInvalid}>确认更新准入</button></div>
</form>;
}
export function Images({admin = false}: {admin?:boolean}) {
function ImageStatusCell({image}: {image: Image}) {
return <div className="grid gap-1 justify-items-start">
<Status value={image.status} />
<p className="m-0 text-[13px]">{admissionLabels[image.admission || ''] || '未验证'}</p>
<small className="max-w-56 text-xs text-muted-foreground wrap-anywhere">{image.admission_reason || '无准入依据'}</small>
<p className="m-0 text-xs text-muted-foreground">{image.selectable && image.status === 'available' ? '可创建 / 升级' : '不可创建 / 升级'}</p>
</div>;
}
export function Images({admin = false}: {admin?: boolean}) {
const [admissionId, setAdmissionId] = useState<string>();
const [page, setPage] = useState(0);
const images = useResource(api.images), builds = useResource(() => api.builds(21, page * 20), String(page), 5000, rows => rows.slice(0, 20).some(activeBuild)); const action = useAction();
const images = useResource(api.images), builds = useResource(() => api.builds(21, page * 20), String(page), 5000, rows => rows.slice(0, 20).some(activeBuild));
const action = useAction();
const confirm = useConfirm();
const [selected, setSelected] = useState<string>();
const rows = builds.data?.slice(0, 20);
function changePage(next: number) { setSelected(undefined); setPage(next); }
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}
<Panel title="本地镜像" action={<button disabled={images.loading} onClick={images.refresh}>刷新</button>}>
<p className="callout">镜像仅保存在本机。仅已验证且允许选择的 available 镜像可创建或升级;existing_only 仅允许存量启停。blocked 禁止新建、升级及新的启动/重启,但不会自动停止已运行实例或断开连接。</p>
<ResourceState resource={images}/>{images.data?.length === 0 && <Empty>本机暂无托管镜像。请联系管理员构建并验证发布镜像。</Empty>}{!!images.data?.length && <div className="table-scroll">
<table>
<thead>
<tr>
<th>DSH 版本</th>
<th>镜像标识</th>
<th>标签</th>
<th>状态</th>
<th>使用/操作</th>
</tr>
</thead>
<tbody>{images.data.map(i => <tr key={i.image_id || i.id}>
<td>
<strong>{i.version}</strong>
</td>
<td className="mono wrap">{i.image_id || i.id}</td>
<td>{i.tag || '—'}</td>
<td>
<Status value={i.status}/>
<p>{({recommended:'推荐',compatible:'兼容',existing_only:'仅存量',blocked:'禁止使用'} as Record<string,string>)[i.admission || ''] || '未验证'}</p>
<small>{i.admission_reason || '无准入依据'}</small>
<p>{i.selectable && i.status === 'available' ? '可创建 / 升级' : '不可创建 / 升级'}</p>
</td>
<td>{i.usage_count ?? 0} 个实例 {admin && <button onClick={() => setAdmissionId(admissionId === (i.image_id || i.id) ? undefined : (i.image_id || i.id))}>准入管理</button>}{admissionId === (i.image_id || i.id) && admin && <Admission key={admissionId} imageId={admissionId} refresh={images.refresh}/>} {admin && <button className="danger" disabled={action.busy || !!i.usage_count} onClick={() => action.run(async () => {await api.request('/images?image_id=' + encodeURIComponent(i.image_id || i.id), 'DELETE'); await images.refresh();}, '镜像已删除', '确认删除这个本地镜像?服务器将检查所有引用,不会强制删除。')}>清理镜像</button>}</td>
</tr>)}</tbody>
</table>
</div>}</Panel>
<Panel title="构建任务" action={<>
<span className="muted">当前页有活跃任务时每 5 秒刷新</span>
<button disabled={builds.loading} onClick={builds.refresh}>刷新任务</button>
</>}>
<ResourceState resource={builds}/>{builds.data?.length === 0 && <Empty>没有构建任务记录</Empty>}{!!builds.data?.length && <div className="table-scroll">
<table>
<thead>
<tr>
<th>任务</th>
<th>版本</th>
<th>状态</th>
<th>提交时间</th>
<th>日志</th>
</tr>
</thead>
<tbody>{rows!.map(b => <tr key={b.id}>
<td className="mono">{b.id}</td>
<td>{b.version}{b.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}<BuildProvenance build={b}/></td>
<td>
<Status value={b.status}/>
</td>
<td>{date(b.created_at)}</td>
<td>
<button onClick={() => setSelected(b.id)}>查看日志</button>
</td>
</tr>)}</tbody>
</table>
</div>}<div className="actions">
<button disabled={page === 0 || builds.loading} onClick={() => changePage(page - 1)}>上一页</button>
<span>第 {page + 1} 页</span>
<button disabled={builds.loading || !builds.data || builds.data.length <= 20} onClick={() => changePage(page + 1)}>下一页</button>
</div>{selected && <BuildLogs key={selected} id={selected} close={() => setSelected(undefined)}/>}</Panel>
</>;
const imageActions = (i: Image) => <>
{admin && <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} onClick={() => setAdmissionId(admissionId === (i.image_id || i.id) ? undefined : (i.image_id || i.id))}>准入管理</button>}
{admin && <button className={cn(buttonVariants({variant: 'destructive-ghost', size: 'sm'}))} disabled={action.busy || !!i.usage_count} onClick={async () => {
if (!await confirm.ask({title: '清理镜像', danger: true, confirmLabel: '删除镜像', body: '确认删除这个本地镜像?服务器将检查所有引用,不会强制删除。'})) return;
void action.run(async () => { await api.request('/images?image_id=' + encodeURIComponent(i.image_id || i.id), 'DELETE'); await images.refresh(); }, '镜像已删除');
}}>清理镜像</button>}
</>;
return <>
{confirm.element}
{action.feedback}
{admin && <ReleasePanel onBuilt={async () => { if (page === 0) await builds.refresh(); else changePage(0); }} />}
<Panel title="本地镜像" action={<RefreshButton loading={images.loading} onClick={images.refresh} />}>
<AlertBox variant="info" className="mb-4">镜像仅保存在本机。仅已验证且允许选择的 available 镜像可创建或升级;existing_only 仅允许存量启停。blocked 禁止新建、升级及新的启动/重启,但不会自动停止已运行实例或断开连接。</AlertBox>
<ResourceState resource={images} />
{images.data?.length === 0 && <Empty>本机暂无托管镜像。请联系管理员构建并验证发布镜像。</Empty>}
{!!images.data?.length && <>
<div className="hidden overflow-x-auto md:block">
<table className={tableClass}>
<thead><tr className={tableHeadRowClass}>
<th className={cn(thClass)}>DSH 版本</th>
<th className={cn(thClass)}>镜像标识</th>
<th className={cn(thClass)}>标签</th>
<th className={cn(thClass)}>状态</th>
<th className={cn(thClass)}>使用/操作</th>
</tr></thead>
<tbody>{images.data.map(i => <tr key={i.image_id || i.id} className={trClass}>
<td className={tdClass}><strong>{i.version}</strong></td>
<td className="max-w-64 border-b border-border px-3 py-3 align-top font-mono text-xs wrap-anywhere">{i.image_id || i.id}</td>
<td className={tdClass}>{i.tag || '—'}</td>
<td className={tdClass}><ImageStatusCell image={i} /></td>
<td className={tdClass}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[13px] text-muted-foreground">{i.usage_count ?? 0} 个实例</span>
{imageActions(i)}
</div>
</td>
</tr>)}</tbody>
</table>
</div>
<div className="grid gap-3 md:hidden">
{images.data.map(i => <div key={i.image_id || i.id} className="rounded-lg border border-border bg-card p-3.5">
<div className="mb-1 flex items-center justify-between gap-2">
<strong className="text-[14px]">{i.version}</strong>
<Status value={i.status} />
</div>
<p className="m-0 font-mono text-[11px] text-muted-foreground wrap-anywhere">{i.image_id || i.id}</p>
{i.tag && <p className="m-0 mt-0.5 text-xs text-muted-foreground">标签:{i.tag}</p>}
<div className="my-2.5"><ImageStatusCell image={i} /></div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[13px] text-muted-foreground">{i.usage_count ?? 0} 个实例</span>
{imageActions(i)}
</div>
</div>)}
</div>
</>}
</Panel>
{admin && admissionId && images.data?.some(i => (i.image_id || i.id) === admissionId) && <Panel title={'准入管理 · ' + admissionId}>
<Admission key={admissionId} imageId={admissionId} refresh={images.refresh} />
</Panel>}
<Panel title="构建任务" action={<div className="flex items-center gap-2">
<span className="hidden text-xs text-muted-foreground sm:inline">有活跃任务时每 5 秒刷新</span>
<RefreshButton loading={builds.loading} onClick={builds.refresh} label="刷新任务" />
</div>}>
<ResourceState resource={builds} />
{builds.data?.length === 0 && <Empty>没有构建任务记录</Empty>}
{!!builds.data?.length && <>
<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>{rows!.map(b => <tr key={b.id} className={trClass}>
<td className="border-b border-border px-3 py-3 align-top font-mono text-xs">{b.id}</td>
<td className={tdClass}>
{b.version}
{b.experimental && <AlertBox variant="destructive" hideIcon className="mt-1 inline-flex px-2 py-0.5 text-xs font-semibold">实验构建 · 不自动准入</AlertBox>}
<BuildProvenance build={b} />
</td>
<td className={tdClass}><Status value={b.status} /></td>
<td className="border-b border-border px-3 py-3 align-top whitespace-nowrap">{date(b.created_at)}</td>
<td className="border-b border-border px-3 py-3 align-top text-right"><button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} onClick={() => setSelected(b.id)}>查看日志</button></td>
</tr>)}</tbody>
</table>
</div>
<div className="grid gap-2 md:hidden">
{rows!.map(b => <div key={b.id} className="rounded-lg border border-border bg-card p-3.5">
<div className="mb-1 flex items-center justify-between gap-2">
<strong className="text-[14px]">{b.version}</strong>
<Status value={b.status} />
</div>
<p className="m-0 font-mono text-[11px] text-muted-foreground">{b.id}</p>
{b.experimental && <AlertBox variant="destructive" hideIcon className="mt-1.5 inline-flex px-2 py-0.5 text-xs font-semibold">实验构建 · 不自动准入</AlertBox>}
<BuildProvenance build={b} />
<div className="mt-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{date(b.created_at)}</span>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} onClick={() => setSelected(b.id)}>查看日志</button>
</div>
</div>)}
</div>
</>}
<div className="mt-4 flex items-center justify-center gap-3">
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={page === 0 || builds.loading} onClick={() => changePage(page - 1)}><ChevronLeft aria-hidden />上一页</button>
<span className="text-[13px] text-muted-foreground">第 {page + 1} 页</span>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={builds.loading || !builds.data || builds.data.length <= 20} onClick={() => changePage(page + 1)}>下一页<ChevronRight aria-hidden /></button>
</div>
{selected && <BuildLogs key={selected} id={selected} close={() => setSelected(undefined)} />}
</Panel>
</>;
}
+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>;
}
+1 -1
View File
@@ -44,5 +44,5 @@ export default function MonacoProfileEditor({ value, onChange, readOnly = false,
}
}, [value]);
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--color-border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
}
+27 -8
View File
@@ -1,11 +1,30 @@
import { api } from './api';
import { Panel, ResourceState, useAction, useResource } from './ui';
import { Panel, ResourceState, useAction, useResource, RefreshButton } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function Orphans() {
const list=useResource(()=>api.request<string[]>('/orphans')), action=useAction();
return <Panel title="孤儿容器回收" action={<button onClick={list.refresh}>核验并刷新</button>}>
<p>仅列出已停止、归属和挂载核验通过且无实例或升级引用的容器。回收保留所有挂载数据,不删除网络。运行中及来源不明的容器不会列入。</p>
{action.feedback}<ResourceState resource={list}/>
{list.data?.length===0 && <p>没有可安全回收的容器。</p>}
{list.data?.map(id=><div key={id}><code>{id}</code><button disabled={action.busy} onClick={()=>action.run(async()=>{if(window.prompt('请输入完整容器 ID 确认回收(数据保留)')!==id)throw new Error('确认不匹配,未执行');await api.request('/orphans?id='+encodeURIComponent(id)+'&confirm='+encodeURIComponent(id),'DELETE');await list.refresh();},'容器已回收,数据已保留','确认回收此停止的孤儿容器?')}>回收</button></div>)}
</Panel>;
const list = useResource(() => api.request<string[]>('/orphans')), action = useAction();
const confirm = useConfirm();
return <Panel title="孤儿容器回收" action={<RefreshButton loading={list.loading} onClick={list.refresh} label="核验并刷新" />}>
{confirm.element}
<p className="mb-4 mt-0 text-[13px] text-muted-foreground">仅列出已停止、归属和挂载核验通过且无实例或升级引用的容器。回收保留所有挂载数据,不删除网络。运行中及来源不明的容器不会列入。</p>
{action.feedback}
<ResourceState resource={list} />
{list.data?.length === 0 && <p className="m-0 text-[13px] text-muted-foreground">没有可安全回收的容器。</p>}
<div className="grid gap-2">
{list.data?.map(id => <div key={id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2.5">
<code className="wrap-anywhere">{id}</code>
<button className={cn(buttonVariants({variant: 'destructive-ghost', size: 'sm'}))} disabled={action.busy} onClick={async () => {
if (!await confirm.ask({
title: '回收孤儿容器', danger: true, confirmLabel: '确认回收',
body: '回收保留挂载数据、不删除网络,且不可撤销。请在下方输入完整容器 ID 确认。',
requireText: id, requireTextLabel: '完整容器 ID',
})) return;
void action.run(async () => { await api.request('/orphans?id=' + encodeURIComponent(id) + '&confirm=' + encodeURIComponent(id), 'DELETE'); await list.refresh(); }, '容器已回收,数据已保留');
}}>回收</button>
</div>)}
</div>
</Panel>;
}
+20 -8
View File
@@ -3,6 +3,10 @@ import { Dialog } from './Dialog';
import { PasswordField } from './PasswordField';
import { passwordResetError } from './passwordReset';
import { useAction } from './ui';
import { AlertBox } from './components/ui/alert';
import { DialogFooter } from './components/ui/dialog';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function PasswordDialog({title, currentRequired = false, onClose, onSubmit}: {title: string; currentRequired?: boolean; onClose: () => void; onSubmit: (password: string, current: string) => Promise<void>}) {
const [current, setCurrent] = useState(''), [password, setPassword] = useState(''), [confirmation, setConfirmation] = useState('');
@@ -12,14 +16,22 @@ export function PasswordDialog({title, currentRequired = false, onClose, onSubmi
function submit(event: FormEvent) {
event.preventDefault();
if (!valid || action.busy) return;
void action.run(async () => {await onSubmit(password, current); onClose();});
void action.run(async () => { await onSubmit(password, current); onClose(); });
}
return <Dialog title={title} busy={action.busy} onClose={onClose}><form className="dialog-form" onSubmit={submit}>
<p className="dialog-impact">{currentRequired ? '修改后所有设备的登录会话将失效,你将退出登录并需要使用新密码重新登录。忘记当前密码请联系管理员。' : '重设后旧密码及该账号所有登录会话失效;若为当前账号,你也将退出登录。请通过安全渠道告知用户新密码。'}</p>
{currentRequired && <PasswordField label="当前密码" name="current_password" value={current} onChange={setCurrent} disabled={action.busy} autoComplete="current-password"/>}
<PasswordField label="新密码" name="password" value={password} onChange={setPassword} disabled={action.busy} describedBy={hintId}/>
<PasswordField label="确认新密码" name="confirmation" value={confirmation} onChange={setConfirmation} disabled={action.busy} describedBy={hintId}/>
<div id={hintId}><small>新密码须为 12–72 UTF-8 字节,中文等字符占多个字节。当前 {new TextEncoder().encode(password).length} 字节。</small><p className="validation-message" aria-live="polite">{password || confirmation ? error || '密码校验通过' : '请输入并确认新密码'}</p></div>
{currentError && <p role="alert" className="validation-message">{currentError}</p>}{action.feedback}<div className="dialog-actions"><button type="button" disabled={action.busy} onClick={onClose}>取消</button><button className={currentRequired ? 'primary' : 'danger'} disabled={!valid || action.busy}>{action.busy ? '正在提交…' : currentRequired ? '确认修改并退出' : '确认重设密码'}</button></div>
return <Dialog title={title} busy={action.busy} onClose={onClose}><form className="grid gap-4" onSubmit={submit}>
<AlertBox variant="info">{currentRequired ? '修改后所有设备的登录会话将失效,你将退出登录并需要使用新密码重新登录。忘记当前密码请联系管理员。' : '重设后旧密码及该账号所有登录会话失效;若为当前账号,你也将退出登录。请通过安全渠道告知用户新密码。'}</AlertBox>
{currentRequired && <PasswordField label="当前密码" name="current_password" value={current} onChange={setCurrent} disabled={action.busy} autoComplete="current-password" />}
<PasswordField label="新密码" name="password" value={password} onChange={setPassword} disabled={action.busy} describedBy={hintId} />
<PasswordField label="确认新密码" name="confirmation" value={confirmation} onChange={setConfirmation} disabled={action.busy} describedBy={hintId} />
<div id={hintId}>
<small className="text-xs text-muted-foreground">新密码须为 12–72 UTF-8 字节,中文等字符占多个字节。当前 {new TextEncoder().encode(password).length} 字节。</small>
<p className="m-0 mt-1 min-h-5 text-xs text-muted-foreground" aria-live="polite">{password || confirmation ? error || '密码校验通过' : '请输入并确认新密码'}</p>
</div>
{currentError && <p role="alert" className="m-0 text-xs font-medium text-destructive">{currentError}</p>}
{action.feedback}
<DialogFooter>
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={action.busy} onClick={onClose}>取消</button>
<button className={cn(buttonVariants({variant: currentRequired ? 'default' : 'destructive'}))} disabled={!valid || action.busy}>{action.busy ? '正在提交…' : currentRequired ? '确认修改并退出' : '确认重设密码'}</button>
</DialogFooter>
</form></Dialog>;
}
+14 -1
View File
@@ -1,6 +1,19 @@
import { useId, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Input } from './components/ui/input';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function PasswordField({label, name, value, onChange, disabled, autoComplete = 'new-password', describedBy}: {label: string; name: string; value: string; onChange: (value: string) => void; disabled: boolean; autoComplete?: string; describedBy?: string}) {
const [visible, setVisible] = useState(false), id = useId();
return <div className="password-field"><label htmlFor={id}>{label}</label><div className="password-input"><input id={id} name={name} type={visible ? 'text' : 'password'} value={value} onChange={event => onChange(event.target.value)} required disabled={disabled} autoComplete={autoComplete} aria-describedby={describedBy}/><button type="button" aria-label={(visible ? '隐藏' : '显示') + label} aria-pressed={visible} disabled={disabled} onClick={() => setVisible(!visible)}>{visible ? '隐藏' : '显示'}</button></div></div>;
return <div className="grid gap-1.5">
<label htmlFor={id} className="text-[13px] font-medium">{label}</label>
<div className="flex gap-2">
<Input id={id} name={name} type={visible ? 'text' : 'password'} value={value} onChange={event => onChange(event.target.value)} required disabled={disabled} autoComplete={autoComplete} aria-describedby={describedBy} className="flex-1" />
<button type="button" aria-label={(visible ? '隐藏' : '显示') + label} aria-pressed={visible} disabled={disabled} onClick={() => setVisible(!visible)}
className={cn(buttonVariants({variant: 'outline', size: 'icon'}), 'shrink-0 text-muted-foreground')}>
{visible ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
</button>
</div>
</div>;
}
+1 -1
View File
@@ -6,5 +6,5 @@ export type { ProfileEditorProps } from './MonacoProfileEditor';
const MonacoProfileEditor = lazy(() => import('./MonacoProfileEditor'));
export function ProfileEditor(props: ProfileEditorProps) {
return <><p className="muted">{homeAccessNotice} 本页面保存必须先停止实例,建议修改前备份;通用文件管理不受此流程强制约束。</p><Suspense fallback={<div role="group" aria-label={props.label ?? 'YAML 配置'} style={{ height: 420 }}><p role="status">正在加载编辑器…</p></div>}><MonacoProfileEditor {...props}/></Suspense></>;
return <><p className="mb-3 mt-1 text-[13px] text-muted-foreground">{homeAccessNotice} 本页面保存必须先停止实例,建议修改前备份;通用文件管理不受此流程强制约束。</p><Suspense fallback={<div role="group" aria-label={props.label ?? 'YAML 配置'} style={{ height: 420 }}><p role="status" className="text-[13px] text-muted-foreground">正在加载编辑器…</p></div>}><MonacoProfileEditor {...props}/></Suspense></>;
}
+19 -7
View File
@@ -3,6 +3,10 @@ import { api, type User } from './api';
import { Dialog } from './Dialog';
import { bytes, useAction } from './ui';
import { GiB, quotaBytes } from './quota';
import { DialogFooter } from './components/ui/dialog';
import { buttonVariants } from './components/ui/button';
import { Input } from './components/ui/input';
import { cn } from './lib/utils';
export function QuotaDialog({user, onClose, onSaved}: {user: User; onClose: () => void; onSaved: () => void}) {
const [value, setValue] = useState(String(user.quota_bytes / GiB));
@@ -10,13 +14,21 @@ export function QuotaDialog({user, onClose, onSaved}: {user: User; onClose: () =
function submit(event: FormEvent) {
event.preventDefault();
if (!valid || action.busy) return;
void action.run(async () => {await api.request('/users', 'PATCH', {id:user.id, quota_bytes:next}); onSaved(); onClose();});
void action.run(async () => { await api.request('/users', 'PATCH', {id: user.id, quota_bytes: next}); onSaved(); onClose(); });
}
return <Dialog title={'设置配额 · ' + user.username} busy={action.busy} onClose={onClose}><form className="dialog-form" onSubmit={submit}>
<p className="muted">当前工作区配额:<strong>{bytes(user.quota_bytes)}</strong></p>
<label>新配额(GiB)<input name="quota" type="number" min="0.1" step="any" required value={value} disabled={action.busy} onChange={event => setValue(event.target.value)} aria-describedby="quota-hint"/></label>
<div className="quota-presets" role="group" aria-label="常用配额">{[5,10,20,50].map(n => <button key={n} type="button" aria-pressed={next === n * GiB} disabled={action.busy} onClick={() => setValue(String(n))}>{n} GiB</button>)}</div>
<p id="quota-hint" className="muted" aria-live="polite">{next === null ? '请输入至少 0.1 GiB 且在安全整数范围内的有效配额。' : !valid ? '配额未改变,请输入新额度或选择预设。' : '将更新为 ' + bytes(next) + ',已有文件不会被删除。'}</p>
{action.feedback}<div className="dialog-actions"><button type="button" disabled={action.busy} onClick={onClose}>取消</button><button className="primary" disabled={!valid || action.busy}>{action.busy ? '正在保存…' : '保存配额'}</button></div>
return <Dialog title={'设置配额 · ' + user.username} busy={action.busy} onClose={onClose}><form className="grid gap-4" onSubmit={submit}>
<p className="m-0 text-[13px] text-muted-foreground">当前工作区配额:<strong className="text-foreground">{bytes(user.quota_bytes)}</strong></p>
<label className="grid gap-1.5 text-[13px] font-medium">新配额(GiB)
<Input name="quota" type="number" min="0.1" step="any" required value={value} disabled={action.busy} onChange={event => setValue(event.target.value)} aria-describedby="quota-hint" /></label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="group" aria-label="常用配额">
{[5, 10, 20, 50].map(n => <button key={n} type="button" aria-pressed={next === n * GiB} disabled={action.busy} onClick={() => setValue(String(n))}
className={cn(buttonVariants({variant: 'outline', size: 'sm'}), next === n * GiB && 'border-primary bg-secondary text-primary hover:bg-secondary')}>{n} GiB</button>)}
</div>
<p id="quota-hint" className="m-0 text-[13px] text-muted-foreground" aria-live="polite">{next === null ? '请输入至少 0.1 GiB 且在安全整数范围内的有效配额。' : !valid ? '配额未改变,请输入新额度或选择预设。' : '将更新为 ' + bytes(next) + ',已有文件不会被删除。'}</p>
{action.feedback}
<DialogFooter>
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={action.busy} onClick={onClose}>取消</button>
<button className={cn(buttonVariants())} disabled={!valid || action.busy}>{action.busy ? '正在保存…' : '保存配额'}</button>
</DialogFooter>
</form></Dialog>;
}
+34 -19
View File
@@ -17,6 +17,7 @@ vi.mock('react', async original => ({
import { api, ApiError, createApi, type Release } from './api';
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
import { ResourceState } from './ui';
import { ConfirmDialog } from './components/ConfirmDialog';
const release: Release = {release_id:'release-1', version:'1.2.3', commit:'abc123', repository:'owner/repo', branch:'main', recipe_rev:'7', platforms:['linux/amd64'], compatible:true, reason:'supported'};
const built = {id:'build-1', version:'1.2.3', status:'queued', created_at:''};
const onBuilt = vi.fn(async () => {});
@@ -28,9 +29,13 @@ function nodes(root: any, predicate: (node: any) => boolean): any[] {
function render() {hooks.cursor = 0; const tree = ReleasePanel({onBuilt}); hooks.effects.splice(0).forEach(effect => effect()); return tree;}
const button = () => nodes(render(), n => n.type === 'button')[0];
const settle = () => vi.advanceTimersByTimeAsync(0);
// In-app confirm dialog replaced window.confirm: read/resolve via ConfirmDialog props.
const dialog = () => nodes(render(), n => n.type === ConfirmDialog)[0];
const confirmBody = () => JSON.stringify(dialog().props.request?.options.body ?? '');
const clickAndConfirm = async () => { button().props.onClick(); dialog().props.onClose(true); await settle(); await settle(); await settle(); };
beforeEach(() => {
hooks.slots = []; hooks.cursor = 0; hooks.effects = []; onBuilt.mockClear();
vi.useFakeTimers(); vi.stubGlobal('window', {setInterval, clearInterval, confirm:vi.fn(() => false)});
vi.useFakeTimers(); vi.stubGlobal('window', {setInterval, clearInterval});
vi.spyOn(api, 'latestRelease').mockResolvedValue({...release});
vi.spyOn(api, 'buildRelease').mockResolvedValue(built);
vi.spyOn(api, 'build').mockResolvedValue(built);
@@ -46,19 +51,25 @@ it('checks on mount and every five minutes, supports manual refresh, and never a
await vi.advanceTimersByTimeAsync(300_000); expect(api.latestRelease).toHaveBeenCalledTimes(3);
});
it('cancellation sends no mutation; confirmed duplicate clicks submit the exact displayed commit once', async () => {
render(); await settle(); await button().props.onClick();
render(); await settle();
button().props.onClick();
expect(api.buildRelease).not.toHaveBeenCalled();
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining(release.commit));
vi.mocked(window.confirm).mockReturnValue(true);
const submit = button().props.onClick; await Promise.all([submit(), submit()]);
expect(confirmBody()).toContain(release.commit);
dialog().props.onClose(false);
await settle();
expect(api.buildRelease).not.toHaveBeenCalled();
// Rapid duplicate clicks: only the last pending confirmation survives.
button().props.onClick(); button().props.onClick();
dialog().props.onClose(true);
await settle(); await settle(); await settle();
expect(api.buildRelease).toHaveBeenCalledExactlyOnceWith(release.commit);
expect(onBuilt).toHaveBeenCalledOnce();
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build).toEqual(built);
});
it.each(['succeeded', 'failed'])('polls the accepted task to %s when latest omits its build', async status => {
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
await button().props.onClick(); render(); await settle();
render(); await settle();
await clickAndConfirm(); render(); await settle();
expect(api.build).toHaveBeenCalledExactlyOnceWith(built.id);
expect(button().props.disabled).toBe(true);
vi.mocked(api.build).mockResolvedValue({...built, status, error:status === 'failed' ? 'retry failed' : undefined});
@@ -77,12 +88,12 @@ it('prefers a new retry over an old failed build and ignores earlier same-commit
vi.mocked(api.build).mockResolvedValue(old);
render(); await settle(); render(); await settle();
expect(button().props.disabled).toBe(false);
vi.mocked(window.confirm).mockReturnValue(true);
await button().props.onClick();
await clickAndConfirm();
vi.mocked(api.build).mockResolvedValue({...built, status:'running'});
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.id).toBe(built.id);
await button().props.onClick(); expect(api.buildRelease).toHaveBeenCalledTimes(1);
button().props.onClick(); await settle();
expect(api.buildRelease).toHaveBeenCalledTimes(1);
await settle(); expect(api.build).toHaveBeenLastCalledWith(built.id);
expect(nodes(render(), n => n.props?.role === 'alert')).toHaveLength(0);
vi.mocked(api.latestRelease).mockResolvedValue({...release, build:built});
@@ -93,7 +104,7 @@ it('prefers a new retry over an old failed build and ignores earlier same-commit
expect(button().props.disabled).toBe(false);
expect(JSON.stringify(render())).toContain('new failure');
vi.mocked(api.buildRelease).mockResolvedValue({...built, id:'build-2'});
await button().props.onClick();
await clickAndConfirm();
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.id).toBe('build-2');
});
@@ -101,7 +112,9 @@ it('blocks incompatible releases and shows the server reason', async () => {
vi.mocked(api.latestRelease).mockResolvedValue({...release, compatible:false, reason:'unsupported recipe'});
render(); await settle(); expect(button().props.disabled).toBe(true);
expect(JSON.stringify(render())).toContain('unsupported recipe');
await button().props.onClick(); expect(window.confirm).not.toHaveBeenCalled();
button().props.onClick(); await settle();
// Incompatible release never opens the confirmation dialog.
expect(dialog().props.request).toBeUndefined();
expect(api.buildRelease).not.toHaveBeenCalled();
});
it('blocks stale data after refresh failure and exposes retry feedback', async () => {
@@ -111,16 +124,17 @@ it('blocks stale data after refresh failure and exposes retry feedback', async (
expect(nodes(render(), n => n.type === ResourceState)[0].props.resource.error).toBe('offline');
});
it('409 refreshes the branch without retrying a build, and requires new confirmation', async () => {
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
render(); await settle();
vi.mocked(api.buildRelease).mockRejectedValueOnce(new ApiError(409, 'changed'));
vi.mocked(api.latestRelease).mockResolvedValue({...release, commit:'def456'});
await button().props.onClick();
await clickAndConfirm();
expect(api.buildRelease).toHaveBeenCalledTimes(1);
expect(JSON.stringify(render())).toContain('再次明确确认');
vi.mocked(window.confirm).mockReturnValue(false); await button().props.onClick();
expect(window.confirm).toHaveBeenLastCalledWith(expect.stringContaining('def456'));
button().props.onClick();
expect(confirmBody()).toContain('def456');
dialog().props.onClose(false); await settle();
expect(api.buildRelease).toHaveBeenCalledTimes(1);
vi.mocked(window.confirm).mockReturnValue(true); await button().props.onClick();
await clickAndConfirm();
expect(api.buildRelease).toHaveBeenLastCalledWith('def456');
});
it('shows build errors and optional provenance without affecting legacy builds', async () => {
@@ -132,9 +146,10 @@ it('shows build errors and optional provenance without affecting legacy builds',
expect(BuildProvenance({build:built})).toBeNull();
});
it('surfaces build request failure without refreshing or upgrading', async () => {
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
render(); await settle();
vi.mocked(api.buildRelease).mockRejectedValue(new ApiError(500, 'builder unavailable'));
await button().props.onClick(); expect(JSON.stringify(render())).toContain('builder unavailable');
await clickAndConfirm();
expect(JSON.stringify(render())).toContain('builder unavailable');
expect(onBuilt).not.toHaveBeenCalled(); expect(api.latestRelease).toHaveBeenCalledTimes(1);
});
it('uses the assigned endpoints and sends only the pinned commit', async () => {
+42 -23
View File
@@ -1,19 +1,24 @@
import { useState } from 'react';
import { api, ApiError, type Build } from './api';
import { Panel, ResourceState, Status, useAction, useResource } from './ui';
import { Panel, ResourceState, Status, useAction, useResource, RefreshButton } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { AlertBox } from './components/ui/alert';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function BuildProvenance({build}: {build: Build}) {
if (!build.release_id && !build.source_commit && !build.source_repository) return null;
return <div className="mono wrap">
{build.release_id && <p>发布标识:{build.release_id}</p>}
{build.source_repository && <p>源码仓库:{build.source_repository}</p>}
{build.source_commit && <p>源码提交:{build.source_commit}</p>}
return <div className="mt-1 grid gap-0.5 font-mono text-xs text-muted-foreground wrap-anywhere">
{build.release_id && <p className="m-0">发布标识:{build.release_id}</p>}
{build.source_repository && <p className="m-0">源码仓库:{build.source_repository}</p>}
{build.source_commit && <p className="m-0">源码提交:{build.source_commit}</p>}
</div>;
}
export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
const release = useResource(api.latestRelease, '', 300_000);
const action = useAction();
const confirm = useConfirm();
const [submitted, setSubmitted] = useState<{commit: string; build: Build}>();
const current = release.data;
const accepted = submitted?.commit === current?.commit ? submitted?.build : undefined;
@@ -25,6 +30,16 @@ export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
const disabled = action.busy || release.loading || !!release.error || !current?.compatible || !!active;
async function submit() {
if (disabled || !current) return;
const ok = await confirm.ask({
title: '手动构建源码发布',
confirmLabel: '确认构建',
body: <div className="grid gap-1">
<p className="m-0">确认手动构建源码发布 <strong className="text-foreground">{current.version}</strong>?</p>
<p className="m-0 font-mono text-xs">仓库:{current.repository}<br />分支:{current.branch}<br />提交:{current.commit}<br />部署协议:{current.recipe_rev}<br />平台:{current.platforms.join(', ')}</p>
<p className="m-0">构建会消耗磁盘、网络与 CPU;不会自动升级实例,构建成功不等于通过验收。</p>
</div>,
});
if (!ok) return;
await action.run(async () => {
let result: Build;
try { result = await api.buildRelease(current.commit); }
@@ -37,25 +52,29 @@ export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
}
setSubmitted({commit: current.commit, build: result});
await Promise.all([release.refresh(), onBuilt()]);
}, '源码发布构建任务已提交,不会自动升级实例。',
`确认手动构建源码发布 ${current.version}?\n仓库:${current.repository}\n分支:${current.branch}\n提交:${current.commit}\n部署协议:${current.recipe_rev}\n平台:${current.platforms.join(', ')}\n构建会消耗磁盘、网络与 CPU;不会自动升级实例,构建成功不等于通过验收。`);
}, '源码发布构建任务已提交,不会自动升级实例。');
}
return <Panel title="源码发布(管理员)" action={<button disabled={release.loading || action.busy} onClick={release.refresh}>刷新源码发布</button>}>
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级。新构建由管理员在此提交,创建或升级须另行选择已验证的镜像。</p>
<ResourceState resource={release}/>{associated && <ResourceState resource={task}/>} {action.feedback}
{current && <>
<h3>{current.version}</h3>
<div className="mono wrap">
<p>发布标识:{current.release_id}</p>
<p>源码仓库:{current.repository}</p>
<p>分支:{current.branch}</p>
<p>源码提交:{current.commit}</p>
return <Panel title="源码发布(管理员)" action={<RefreshButton loading={release.loading || action.busy} onClick={release.refresh} label="刷新源码发布" />}>
{confirm.element}
<p className="mb-4 mt-0 text-[13px] text-muted-foreground">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级。新构建由管理员在此提交,创建或升级须另行选择已验证的镜像。</p>
<ResourceState resource={release} />{associated && <ResourceState resource={task} />} {action.feedback}
{current && <div className="grid gap-3">
<h3 className="m-0 text-[15px] font-semibold">{current.version}</h3>
<div className="grid gap-1 font-mono text-xs text-muted-foreground wrap-anywhere">
<p className="m-0">发布标识:{current.release_id}</p>
<p className="m-0">源码仓库:{current.repository}</p>
<p className="m-0">分支:{current.branch}</p>
<p className="m-0">源码提交:{current.commit}</p>
</div>
<p>部署协议:{current.recipe_rev} · 平台:{current.platforms.join(', ')}</p>
<p className={current.compatible ? 'callout' : 'alert'}>兼容性:{current.compatible ? '兼容,可手动构建' : '不兼容,禁止构建'} · {current.reason}</p>
{build && <div><p>关联构建:{build.id} · <Status value={build.status}/></p><BuildProvenance build={build}/>{build.error && <p className="alert" role="alert">{build.error}</p>}</div>}
<button className="primary" disabled={disabled} onClick={submit}>手动构建此源码发布</button>
</>}
<p className="callout">构建和启动验证通过后可用于实例;发布者仍需完成上线验收,实例升级须另行确认。</p>
<p className="m-0 text-[13px]">部署协议:{current.recipe_rev} · 平台:{current.platforms.join(', ')}</p>
<AlertBox variant={current.compatible ? 'info' : 'destructive'}>兼容性:{current.compatible ? '兼容,可手动构建' : '不兼容,禁止构建'} · {current.reason}</AlertBox>
{build && <div className="rounded-lg border border-border bg-muted/40 p-3">
<p className="m-0 flex flex-wrap items-center gap-2 text-[13px]">关联构建:<span className="font-mono text-xs">{build.id}</span> <Status value={build.status} /></p>
<BuildProvenance build={build} />
{build.error && <AlertBox variant="destructive" className="mt-2">{build.error}</AlertBox>}
</div>}
<div><button className={cn(buttonVariants())} disabled={disabled} onClick={submit}>手动构建此源码发布</button></div>
</div>}
<AlertBox className="mt-4">构建和启动验证通过后可用于实例;发布者仍需完成上线验收,实例升级须另行确认。</AlertBox>
</Panel>;
}
+15 -6
View File
@@ -1,13 +1,22 @@
import { useState } from 'react';
import { api, type User } from './api';
import { PasswordDialog } from './PasswordDialog';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function ResetPassword({user, onReset}: {user: User; onReset: () => Promise<void>}) {
const [open, setOpen] = useState(false), [notice, setNotice] = useState('');
return <><button className="account-action" onClick={() => {setNotice(''); setOpen(true);}}>重设密码</button>{notice && <p className="table-notice" role="status">{notice}</p>}{open && <PasswordDialog title={'重设密码 · ' + user.username} onClose={() => setOpen(false)} onSubmit={async password => {
const result = await api.request<{ok: boolean; reauthenticate: boolean}>('/users/reset-password', 'POST', {id:user.id, password});
if (result.reauthenticate) {window.dispatchEvent(new CustomEvent('session-expired', {detail:'密码已重设,请使用新密码重新登录。'})); return;}
setNotice('密码已重设,该用户需要重新登录。');
void onReset();
}}/> }</>;
return <>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} onClick={() => { setNotice(''); setOpen(true); }}>重设密码</button>
{notice && <p className="m-0 mt-1.5 text-xs text-success" role="status">{notice}</p>}
{open && <PasswordDialog title={'重设密码 · ' + user.username} onClose={() => setOpen(false)} onSubmit={async password => {
const result = await api.request<{ok: boolean; reauthenticate: boolean}>('/users/reset-password', 'POST', {id: user.id, password});
if (result.reauthenticate) {
window.dispatchEvent(new CustomEvent('session-expired', {detail: '密码已重设,请使用新密码重新登录。'}));
return;
}
setNotice('密码已重设,该用户需要重新登录。');
void onReset();
}} />}
</>;
}
+12 -1
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from './api';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function runtimeReturnTarget(hash: string): string | null {
const target = new URLSearchParams(hash.slice('#runtime-renew?'.length)).get('return');
@@ -22,5 +24,14 @@ export function RuntimeRenew() {
});
return () => { active = false; };
}, [target, attempt]);
return <div className="splash"><section><h1>恢复实例会话</h1><p role="status">{!target ? '无效的实例返回地址。' : error || '正在安全续期,完成后将返回实例…'}</p>{error && <button onClick={() => setAttempt(n => n + 1)}>重试续期</button>}<p><a href="/" target="_top">返回面板登录</a></p></section></div>;
return <div className="grid min-h-dvh place-items-center p-6">
<section className="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center shadow-card">
<h1 className="mb-2 text-lg font-semibold">恢复实例会话</h1>
<p role="status" className="mb-4 text-[13px] text-muted-foreground">{!target ? '无效的实例返回地址。' : error || '正在安全续期,完成后将返回实例…'}</p>
<div className="flex flex-wrap justify-center gap-2">
{error && <button className={cn(buttonVariants())} onClick={() => setAttempt(n => n + 1)}>重试续期</button>}
<a href="/" target="_top" className={cn(buttonVariants({variant: 'outline'}))}>返回面板登录</a>
</div>
</section>
</div>;
}
+11 -3
View File
@@ -1,8 +1,16 @@
import type { useRuntimeInstances } from './RuntimeWorkspace';
import { Select } from './components/ui/input';
export function RuntimeSelector({runtime, disabled = false}: {runtime: ReturnType<typeof useRuntimeInstances>; disabled?: boolean}) {
return <label>当前实例<select value={runtime.selectedId || ''} disabled={disabled || !runtime.instances?.length} onChange={e => runtime.select(e.target.value)}>
export function RuntimeSelector({runtime, disabled = false, compact = false}: {runtime: ReturnType<typeof useRuntimeInstances>; disabled?: boolean; compact?: boolean}) {
const select = <Select
aria-label="当前实例"
value={runtime.selectedId || ''}
disabled={disabled || !runtime.instances?.length}
onChange={e => runtime.select(e.target.value)}
className={compact ? 'w-36 sm:w-44' : 'max-w-md'}>
{!runtime.instances?.length && <option value="">{runtime.instances ? '暂无实例' : '等待实例列表'}</option>}
{runtime.instances?.map(instance => <option key={instance.id} value={instance.id}>{instance.name} · {instance.status}</option>)}
</select></label>;
</Select>;
if (compact) return select;
return <label className="grid max-w-md gap-1.5 text-[13px] font-medium">当前实例{select}</label>;
}
+52 -18
View File
@@ -1,8 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ExternalLink, MoreVertical, RefreshCw, RotateCw, Settings2 } from 'lucide-react';
import { api, type Instance } from './api';
import { Alert, message, Status } from './ui';
import { RuntimeSelector } from './RuntimeSelector';
import { runtimeSelectionKey, selectRuntime } from './runtimeSelection';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './components/ui/dropdown-menu';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export function useRuntimeInstances(userId: string, page: string) {
const [instances, setInstances] = useState<Instance[]>();
@@ -72,23 +76,53 @@ export function RuntimeWorkspace({ runtime, visible, manage, settings }: {
const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId);
const url = selected ? '/dsh/' + encodeURIComponent(selected.id) + '/' : '';
useEffect(() => { setFrameError(false); }, [selected?.id, revision]);
return <section className="runtime-workspace" hidden={!visible} aria-label="DSH 工作台">
<div className="runtime-toolbar"><div><strong>{selected?.name || 'DSH'}</strong> {selected && <Status value={selected.status}/>}</div><div className="actions">
<RuntimeSelector runtime={runtime}/>
<button onClick={() => void runtime.refresh()} disabled={runtime.loading}>刷新状态</button>
{selected?.status === 'running' && <><button onClick={() => setRevision(value => value + 1)}>重新加载</button><a className="button" href={url} target="_blank" rel="noopener noreferrer">新窗口打开 ↗</a></>}
</div></div>
{runtime.error && <Alert>{runtime.error} <button onClick={() => void runtime.refresh()} disabled={runtime.loading}>重试</button></Alert>}
{!runtime.instances && runtime.loading && <div className="runtime-state" role="status">正在加载实例…</div>}
{!runtime.instances && !runtime.loading && <div className="runtime-state">暂时无法加载实例,请重试。</div>}
{runtime.instances && !selected && <div className="runtime-state"><h2>还没有实例</h2><p className="muted">创建运行环境后即可在这里使用 DSH。</p><button className="primary" onClick={manage}>创建实例</button></div>}
{selected && selected.status !== 'running' && <div className="runtime-state"><h2>所选实例尚未运行</h2><p className="muted">「{selected.name}」当前状态:{selected.status}。可前往实例管理启动或检查日志。</p><div className="actions"><button className="primary" onClick={manage}>管理 / 启动实例</button><button onClick={settings}>选择其他实例</button></div></div>}
{selected?.status === 'running' && <>{frameError && <Alert>DSH 页面加载失败或登录已失效,请重新加载或在新窗口打开。</Alert>}<iframe key={selected.id + ':' + revision} src={url} title={'DSH · ' + selected.name} referrerPolicy="same-origin" onError={() => setFrameError(true)} onLoad={event => {
try {
const doc = event.currentTarget.contentDocument;
const path = event.currentTarget.contentWindow?.location.pathname;
setFrameError(!!doc && (doc.contentType === 'application/json' || doc.contentType === 'text/plain' || path === '/login' || path === '/'));
} catch { setFrameError(true); }
}}/></>}
const iconBtn = cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'text-muted-foreground');
return <section className={cn('min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-card', visible ? 'flex' : 'hidden')} aria-label="DSH 工作台">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-border px-3 py-2 sm:px-4">
<div className="flex min-w-0 items-center gap-2">
<strong className="truncate text-[14px]">{selected?.name || 'DSH'}</strong>
{selected && <Status value={selected.status} />}
</div>
<div className="flex items-center gap-1.5">
<RuntimeSelector runtime={runtime} compact />
<button type="button" title="刷新状态" aria-label="刷新状态" disabled={runtime.loading} onClick={() => void runtime.refresh()} className={iconBtn}>
<RefreshCw className={runtime.loading ? 'animate-spin' : undefined} aria-hidden />
</button>
{selected?.status === 'running' && <DropdownMenu>
<DropdownMenuTrigger className={iconBtn} aria-label="更多操作" title="更多操作"><MoreVertical aria-hidden /></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRevision(value => value + 1)}><RotateCw aria-hidden />重新加载内嵌页面</DropdownMenuItem>
<DropdownMenuItem onSelect={() => window.open(url, '_blank', 'noopener,noreferrer')}><ExternalLink aria-hidden />新窗口打开</DropdownMenuItem>
<DropdownMenuItem onSelect={manage}><Settings2 aria-hidden />管理实例</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>}
</div>
</div>
{runtime.error && <div className="shrink-0 px-3 pt-2"><Alert>{runtime.error} <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'ml-2')} onClick={() => void runtime.refresh()} disabled={runtime.loading}>重试</button></Alert></div>}
{!runtime.instances && runtime.loading && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere" role="status">正在加载实例…</div>}
{!runtime.instances && !runtime.loading && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere">暂时无法加载实例,请重试。</div>}
{runtime.instances && !selected && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere">
<h2 className="mb-2 text-lg font-semibold">还没有实例</h2>
<p className="mb-4 text-[13px] text-muted-foreground">创建运行环境后即可在这里使用 DSH。</p>
<button className={cn(buttonVariants())} onClick={manage}>创建实例</button>
</div>}
{selected && selected.status !== 'running' && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere">
<h2 className="mb-2 text-lg font-semibold">所选实例尚未运行</h2>
<p className="mb-4 text-[13px] text-muted-foreground">「{selected.name}」当前状态:{selected.status}。可前往实例管理启动或检查日志。</p>
<div className="flex flex-wrap justify-center gap-2">
<button className={cn(buttonVariants())} onClick={manage}>管理 / 启动实例</button>
<button className={cn(buttonVariants({variant: 'outline'}))} onClick={settings}>选择其他实例</button>
</div>
</div>}
{selected?.status === 'running' && <div className="flex min-h-0 flex-1 flex-col">
{frameError && <div className="shrink-0 px-3 pt-2"><Alert>DSH 页面加载失败或登录已失效,请重新加载或在新窗口打开。</Alert></div>}
<iframe className="block min-h-0 w-full flex-1 border-0 bg-white" key={selected.id + ':' + revision} src={url} title={'DSH · ' + selected.name} referrerPolicy="same-origin" onError={() => setFrameError(true)} onLoad={event => {
try {
const doc = event.currentTarget.contentDocument;
const path = event.currentTarget.contentWindow?.location.pathname;
setFrameError(!!doc && (doc.contentType === 'application/json' || doc.contentType === 'text/plain' || path === '/login' || path === '/'));
} catch { setFrameError(true); }
}} />
</div>}
</section>;
}
+61 -29
View File
@@ -4,6 +4,7 @@ vi.mock('react', async importOriginal => ({
...await importOriginal<typeof import('react')>(),
useState(value: unknown) { const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}]; },
useRef(value: unknown) {const i = state.cursor++; return state.slots[i] ??= {current: value};},
useEffect() {},
}));
vi.mock('./ui', async importOriginal => ({
...await importOriginal<typeof import('./ui')>(),
@@ -12,6 +13,8 @@ vi.mock('./ui', async importOriginal => ({
vi.mock('./api', () => ({api: {download: vi.fn(() => '/download-test'), upload: vi.fn(async () => {}), rename: vi.fn(async () => {}), delete: vi.fn(async () => {})}}));
import { Workspace } from './Workspace';
import { api } from './api';
import { ConfirmDialog } from './components/ConfirmDialog';
import { DialogContent } from './components/ui/dialog';
function render() {
state.cursor = 0;
@@ -24,61 +27,90 @@ function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
const textOf = (node: any): unknown => Array.isArray(node.props.children) ? node.props.children.find((c: unknown) => typeof c === 'string') : node.props.children;
const byText = (text: string) => (n: any) => n.type === 'button' && textOf(n) === text;
// In-app confirm dialog replaced window.confirm: read options and resolve via onClose.
const dialog = () => nodes(render(), n => n.type === ConfirmDialog)[0];
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
const locationAssign = () => vi.mocked((window as any).location.assign);
beforeEach(() => {
state.slots = []; state.cursor = 0; state.entries = [{name: '.credentials.yaml', is_dir: false}];
vi.clearAllMocks(); vi.stubGlobal('window', {confirm: vi.fn(() => false)});
vi.clearAllMocks(); vi.stubGlobal('window', {location: {assign: vi.fn()}});
});
afterEach(() => vi.unstubAllGlobals());
it.each([false, true])('cancel blocks download navigation and acceptance preserves owner access (directory=%s)', is_dir => {
it.each([false, true])('cancel blocks download navigation and acceptance preserves owner access (directory=%s)', async is_dir => {
state.entries = [{name: is_dir ? 'photos' : 'renamed.txt', is_dir}];
const link = nodes(render(), n => n.type === 'a')[0];
for (const handler of ['onClick', 'onAuxClick', 'onContextMenu']) {
const event = {preventDefault: vi.fn(), button: 1};
link.props[handler](event);
expect(event.preventDefault).toHaveBeenCalledOnce();
vi.mocked(window.confirm).mockReturnValueOnce(true);
const accepted = {preventDefault: vi.fn(), button: 1};
link.props[handler](accepted);
expect(accepted.preventDefault).not.toHaveBeenCalled();
// Both the desktop table and the mobile cards render a download button.
const buttons = nodes(render(), n => n.type === 'button' && typeof n.props.title === 'string' && n.props.title.startsWith('确认下载'));
expect(buttons.length).toBeGreaterThan(0);
for (const link of buttons) {
await link.props.onClick();
expect(dialog().props.request.options.title).toContain('下载');
expect(JSON.stringify(dialog().props.request.options.body)).toContain(is_dir ? 'ZIP' : '凭据');
dialog().props.onClose(false);
}
expect(link.props.href).toBe('/download-test');
expect(window.confirm).toHaveBeenCalledTimes(6);
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining(is_dir ? 'ZIP' : '凭据'));
});
it('does not double-prompt auxiliary right-click after the context menu', () => {
nodes(render(), n => n.type === 'a')[0].props.onAuxClick({button: 2, preventDefault: vi.fn()});
expect(window.confirm).not.toHaveBeenCalled();
await flush();
expect(locationAssign()).not.toHaveBeenCalled();
await buttons[0].props.onClick();
dialog().props.onClose(true);
await flush();
expect(locationAssign()).toHaveBeenCalledExactlyOnceWith('/download-test');
});
it('canceled upload sends no request, resets input, and accepted upload needs no stop', async () => {
const input = nodes(render(), n => n.type === 'input' && n.props.type === 'file')[0];
const file = {name: '.credentials.yaml'};
const event = {target: {files: [file], value: 'chosen'}};
input.props.onChange(event);
dialog().props.onClose(false);
await flush();
expect(api.upload).not.toHaveBeenCalled();
expect(event.target.value).toBe('');
vi.mocked(window.confirm).mockReturnValue(true);
input.props.onChange(event);
await Promise.resolve();
expect(JSON.stringify(dialog().props.request.options.body)).toContain('备份');
dialog().props.onClose(true);
await flush(); await flush();
expect(api.upload).toHaveBeenCalledExactlyOnceWith('.', file, 'one');
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('备份'));
});
it('canceled delete sends no request; accepted delete retains full-home access', async () => {
const button = nodes(render(), n => n.type === 'button' && n.props.children === '删除')[0];
const button = nodes(render(), byText('删除'))[0];
await button.props.onClick();
dialog().props.onClose(false);
await flush();
expect(api.delete).not.toHaveBeenCalled();
vi.mocked(window.confirm).mockReturnValue(true);
await button.props.onClick();
dialog().props.onClose(true);
await flush(); await flush();
expect(api.delete).toHaveBeenCalledExactlyOnceWith('.credentials.yaml', 'one');
});
it('rename cancellation preserves the draft without writing; accepting writes once', async () => {
nodes(render(), n => n.type === 'button' && n.props.children === '重命名')[0].props.onClick();
const form = nodes(render(), n => n.type === 'form')[1];
form.props.onSubmit({preventDefault: vi.fn()});
nodes(render(), byText('重命名'))[0].props.onClick();
const form = () => nodes(render(), n => n.type === 'form')[1];
form().props.onSubmit({preventDefault: vi.fn()});
dialog().props.onClose(false);
await flush();
expect(api.rename).not.toHaveBeenCalled();
// The rename draft dialog stays open after declining the destructive confirm.
expect(nodes(render(), n => n.type === 'form')).toHaveLength(2);
vi.mocked(window.confirm).mockReturnValue(true);
form.props.onSubmit({preventDefault: vi.fn()});
await Promise.resolve();
form().props.onSubmit({preventDefault: vi.fn()});
dialog().props.onClose(true);
await flush(); await flush();
expect(api.rename).toHaveBeenCalledExactlyOnceWith('.credentials.yaml', '.credentials.yaml', 'one');
});
it('keeps a failed rename draft and its error inside the input dialog', async () => {
vi.mocked(api.rename).mockRejectedValueOnce(new Error('rename denied'));
nodes(render(), byText('重命名'))[0].props.onClick();
const modal = () => nodes(render(), n => n.type === DialogContent)[0];
nodes(modal(), n => n.type === 'input')[0].props.onChange({target: {value: 'new-name'}});
nodes(modal(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
dialog().props.onClose(true);
await flush();
expect(nodes(modal(), n => n.type === 'input')[0].props.value).toBe('new-name');
expect(JSON.stringify(modal())).toContain('rename denied');
expect(api.rename).toHaveBeenCalledExactlyOnceWith('.credentials.yaml', 'new-name', 'one');
});
+167 -14
View File
@@ -1,34 +1,187 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { api } from './api';
import { confirmDownload, downloadWarning, homeAccessNotice, mutationWarning } from './fileWarnings';
import { Alert, Panel, ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { ChevronRight, Download, File, Folder, FolderPlus, Pencil, RefreshCw, Trash2, Upload } from 'lucide-react';
import { api, type Entry } from './api';
import { downloadWarning, homeAccessNotice, mutationWarning } from './fileWarnings';
import { Alert, Panel, ResourceState, Empty, bytes, date, useAction, useResource, tableClass, tableHeadRowClass, thClass, trClass } from './ui';
import { RuntimeSelector } from './RuntimeSelector';
import type { useRuntimeInstances } from './RuntimeWorkspace';
import { displayFilePath, fileScopeKey, joinFilePath, navigateDirectory } from './workspaceLocation';
import { useConfirm } from './components/ConfirmDialog';
import { DialogClose, DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog';
import { buttonVariants } from './components/ui/button';
import { inputClass } from './components/ui/input';
import { AlertBox } from './components/ui/alert';
import { cn } from './lib/utils';
export function Workspace({runtime}: {runtime: ReturnType<typeof useRuntimeInstances>}) {
const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId);
const controls = (busy: boolean) => <>
<div className="actions">
<RuntimeSelector runtime={runtime} disabled={busy}/><button disabled={busy || runtime.loading} onClick={() => void runtime.refresh()}>刷新实例</button></div>
{runtime.loading && <p role="status" className="muted">正在刷新实例…</p>}
const controls = (busy: boolean) => <div className="mb-4 flex flex-wrap items-center gap-2">
<RuntimeSelector runtime={runtime} disabled={busy} compact />
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={busy || runtime.loading} onClick={() => void runtime.refresh()}>
<RefreshCw className={runtime.loading ? 'animate-spin' : undefined} aria-hidden />刷新实例
</button>
{runtime.loading && <span role="status" className="text-[13px] text-muted-foreground">正在刷新实例…</span>}
{runtime.error && <Alert>{runtime.error}</Alert>}
</>;
</div>;
if (!selected) return <>{controls(false)}<Empty>{runtime.loading ? '正在加载实例列表…' : runtime.error ? '无法确认当前实例,请刷新实例重试。' : '暂无实例,请先创建实例。'}</Empty></>;
return <WorkspaceScope key={fileScopeKey('instance', runtime.selectedId)} instanceId={selected.id} instanceName={selected?.name} controls={controls}/>;
return <WorkspaceScope key={fileScopeKey('instance', runtime.selectedId)} instanceId={selected.id} instanceName={selected?.name} controls={controls} />;
}
type FileProps = {instanceId: string; instanceName?: string; controls: (busy: boolean) => ReactNode};
function WorkspaceScope(props: FileProps) {
const [path, setPath] = useState('.');
return <WorkspaceFiles key={path} {...props} path={path} setPath={setPath}/>;
return <WorkspaceFiles key={path} {...props} path={path} setPath={setPath} />;
}
/** Trigger the browser download after the user confirmed in-app. */
export function navigateDownload(url: string) {
if (typeof window !== 'undefined' && window.location) window.location.assign(url);
}
function WorkspaceFiles({instanceId, instanceName, controls, path, setPath}: FileProps & {path: string; setPath: (path: string) => void}) {
const tree = useResource(() => api.tree(path, instanceId)), usage = useResource(() => api.usage(instanceId));
const action = useAction();
const confirm = useConfirm();
const go = (target: string) => setPath(navigateDirectory(path, target, action.busy));
const [newName,setNewName] = useState(''), [rename,setRename] = useState<{path:string;name:string}>();
const [newName, setNewName] = useState(''), [rename, setRename] = useState<{path: string; name: string}>();
const join = (name: string) => joinFilePath(path, name);
const refresh = async () => {await Promise.all([tree.refresh(), usage.refresh()]);};
function mkdir(e: FormEvent) {e.preventDefault(); void action.run(async () => {await api.mkdir(join(newName), instanceId); setNewName(''); await refresh();}, '目录已创建');}
return <>{controls(action.busy)}<div className="workspace-banner"><div><p className="eyebrow">INSTANCE FILES</p><h2>实例文件</h2><p>{'当前实例:' + instanceName}</p><p>实例数据目录为 /workspace(模板 7);历史镜像的路径可能不同。</p><p>这里管理文件,不会切换 DSH 会话的工作目录。包含配置和密钥,请谨慎操作。</p><p>{homeAccessNotice}</p><p className="muted">下载确认及文件名识别仅为操作提醒,不是权限边界;右键打开下载链接等方式可能绕过提示。</p></div><div><p>账号总用量</p><strong>{bytes(usage.data?.used_bytes)}</strong><p>账号配额:{bytes(usage.data?.quota_bytes)}</p></div></div><ResourceState resource={usage}/>{action.feedback}<Panel title="文件浏览器" action={<button onClick={refresh} disabled={tree.loading || action.busy}>刷新</button>}><p className="muted">当前完整路径:<code>{displayFilePath('instance', path)}</code></p><div className="file-toolbar"><div className="breadcrumbs"><button onClick={() => go('.')} disabled={path === '.' || action.busy}>{instanceId ? '实例文件' : '历史共享目录'}</button>{path !== '.' && path.split('/').map((part,index,parts) => <span key={index}> / <button disabled={action.busy} onClick={() => go(parts.slice(0,index+1).join('/'))}>{part}</button></span>)}</div><label className={'button upload ' + (action.busy ? 'disabled' : '')}>↑ 上传文件<input type="file" disabled={action.busy} onChange={e => {const file = e.target.files?.[0]; if(file) void action.run(async () => {await api.upload(path,file,instanceId); await refresh();}, '文件已上传', mutationWarning('upload', join(file.name))); e.target.value = '';}}/></label></div><form className="compact-form" onSubmit={mkdir}><label className="sr-only" htmlFor="new-folder">新目录名称</label><input disabled={action.busy} id="new-folder" value={newName} onChange={e => setNewName(e.target.value)} placeholder="新目录名称" required pattern="[^/\\]+"/><button disabled={action.busy}>创建目录</button></form>{rename && <form className="compact-form" onSubmit={e => {e.preventDefault(); void action.run(async () => {await api.rename(rename.path,join(rename.name),instanceId); setRename(undefined); await refresh();}, '重命名已完成', mutationWarning('rename', rename.path, join(rename.name)));}}><label>新名称<input disabled={action.busy} required value={rename.name} pattern="[^/\\]+" onChange={e => setRename({...rename,name:e.target.value})}/></label><button disabled={action.busy}>确认重命名</button><button type="button" disabled={action.busy} onClick={() => setRename(undefined)}>取消</button></form>}<ResourceState resource={tree}/>{tree.data?.length === 0 && <Empty>这个目录还是空的。上传文件或创建目录开始使用。</Empty>}{!!tree.data?.length && <div className="table-scroll"><table><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th>操作</th></tr></thead><tbody>{tree.data.map(entry => {const target = entry.path || join(entry.name); return <tr key={entry.name}><td>{entry.is_dir ? <button className="file-link" disabled={action.busy} onClick={() => go(target)}>▰ {entry.name}</button> : <span>▤ {entry.name}</span>}</td><td>{entry.is_dir ? '目录' : bytes(entry.size)}</td><td>{date(entry.mod_time)}</td><td><div className="actions"><a className="button" href={api.download(target,instanceId)} title={downloadWarning(target, entry.is_dir)} onClick={e => confirmDownload(e, target, entry.is_dir)} onAuxClick={e => {if (e.button === 1) confirmDownload(e, target, entry.is_dir);}} onContextMenu={e => confirmDownload(e, target, entry.is_dir)}>下载</a><button disabled={action.busy} onClick={() => setRename({path:target,name:entry.name})}>重命名</button><button className="danger" disabled={action.busy} onClick={() => action.run(async () => {await api.delete(target,instanceId); await refresh();}, '已删除', mutationWarning('delete', target))}>删除</button></div></td></tr>;})}</tbody></table></div>}</Panel></>;
const refresh = async () => { await Promise.all([tree.refresh(), usage.refresh()]); };
function mkdir(e: FormEvent) { e.preventDefault(); void action.run(async () => { await api.mkdir(join(newName), instanceId); setNewName(''); await refresh(); }, '目录已创建'); }
async function downloadEntry(target: string, isDir: boolean) {
if (!await confirm.ask({title: '下载' + (isDir ? '目录' : '文件'), confirmLabel: '下载', body: downloadWarning(target, isDir)})) return;
navigateDownload(api.download(target, instanceId));
}
async function deleteEntry(target: string) {
if (!await confirm.ask({title: '删除', danger: true, confirmLabel: '永久删除', body: mutationWarning('delete', target)})) return;
void action.run(async () => { await api.delete(target, instanceId); await refresh(); }, '已删除');
}
async function submitRename(e: FormEvent) {
e.preventDefault();
if (!rename) return;
if (!await confirm.ask({title: '重命名', confirmLabel: '确认重命名', body: mutationWarning('rename', rename.path, join(rename.name))})) return;
void action.run(async () => { await api.rename(rename.path, join(rename.name), instanceId); setRename(undefined); await refresh(); }, '重命名已完成');
}
const actionButtons = (entry: Entry) => {
const target = entry.path || join(entry.name);
return <>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} title={downloadWarning(target, entry.is_dir)} onClick={() => void downloadEntry(target, entry.is_dir)}><Download aria-hidden />下载</button>
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}))} disabled={action.busy} onClick={() => setRename({path: target, name: entry.name})}><Pencil aria-hidden />重命名</button>
<button className={cn(buttonVariants({variant: 'ghost', size: 'sm'}), 'text-destructive hover:bg-destructive-muted')} disabled={action.busy} onClick={() => void deleteEntry(target)}><Trash2 aria-hidden />删除</button>
</>;
};
return <>
{controls(action.busy)}
{confirm.element}
<div className="mb-5 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-secondary/50 p-4 sm:p-5">
<div className="min-w-0">
<p className="m-0 text-[10px] font-bold tracking-[0.22em] text-primary">INSTANCE FILES</p>
<h2 className="mb-0.5 mt-1 text-lg font-semibold">实例文件</h2>
<p className="m-0 text-[13px] text-muted-foreground">当前实例:{instanceName} · 数据目录 /workspace</p>
<details className="mt-2 text-[13px] text-muted-foreground">
<summary className="cursor-pointer select-none font-medium text-primary">操作须知与敏感文件提醒</summary>
<div className="mt-2 grid max-w-2xl gap-1.5">
<p className="m-0">实例数据目录为 /workspace(模板 7);历史镜像的路径可能不同。这里管理文件,不会切换 DSH 会话的工作目录。</p>
<p className="m-0">包含配置和密钥,请谨慎操作。{homeAccessNotice}</p>
<p className="m-0">下载确认及文件名识别仅为操作提醒,不是权限边界;右键打开下载链接等方式可能绕过提示。</p>
</div>
</details>
</div>
<div className="shrink-0 text-right">
<p className="m-0 text-xs text-muted-foreground">账号总用量</p>
<strong className="text-xl font-semibold">{bytes(usage.data?.used_bytes)}</strong>
<p className="m-0 text-xs text-muted-foreground">配额:{bytes(usage.data?.quota_bytes)}</p>
</div>
</div>
<ResourceState resource={usage} />
{!rename && action.feedback}
<Panel title="文件浏览器" action={<button title="刷新" aria-label="刷新" className={cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'text-muted-foreground')} onClick={refresh} disabled={tree.loading || action.busy}><RefreshCw className={tree.loading ? 'animate-spin' : undefined} aria-hidden /></button>}>
<p className="mb-3 mt-0 text-[13px] text-muted-foreground">当前完整路径:<code>{displayFilePath('instance', path)}</code></p>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-0.5 text-[13px]">
<button className={cn(buttonVariants({variant: 'link', size: 'sm'}), 'h-auto px-1 py-0.5')} onClick={() => go('.')} disabled={path === '.' || action.busy}>{instanceId ? '实例文件' : '历史共享目录'}</button>
{path !== '.' && path.split('/').map((part, index, parts) => <span key={index} className="inline-flex items-center gap-0.5 text-muted-foreground">
<ChevronRight className="size-3.5" aria-hidden />
<button className={cn(buttonVariants({variant: 'link', size: 'sm'}), 'h-auto px-1 py-0.5')} disabled={action.busy} onClick={() => go(parts.slice(0, index + 1).join('/'))}>{part}</button>
</span>)}
</div>
<label className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'relative overflow-hidden', action.busy && 'pointer-events-none opacity-50')}>
<Upload aria-hidden />上传文件
<input type="file" className="absolute inset-0 w-full cursor-pointer opacity-0" disabled={action.busy} onChange={e => {
const file = e.target.files?.[0];
if (file) void (async () => {
if (!await confirm.ask({title: '上传文件', confirmLabel: '上传', body: mutationWarning('upload', join(file.name))})) return;
void action.run(async () => { await api.upload(path, file, instanceId); await refresh(); }, '文件已上传');
})();
e.target.value = '';
}} />
</label>
</div>
<form className="mb-4 flex max-w-md gap-2" onSubmit={mkdir}>
<label className="sr-only" htmlFor="new-folder">新目录名称</label>
<input className={inputClass} disabled={action.busy} id="new-folder" value={newName} onChange={e => setNewName(e.target.value)} placeholder="新目录名称" required pattern="[^/\\]+" />
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}), 'shrink-0')} disabled={action.busy}><FolderPlus aria-hidden />创建目录</button>
</form>
{rename && <DialogRoot open onOpenChange={open => { if (!open && !action.busy) setRename(undefined); }}>
<DialogContent title={'重命名 · ' + rename.name}>
<form onSubmit={e => void submitRename(e)} className="grid gap-4">
{action.feedback}
<label className="grid gap-1.5 text-[13px] font-medium">新名称
<input className={inputClass} disabled={action.busy} required value={rename.name} pattern="[^/\\]+" onChange={e => setRename({...rename, name: e.target.value})} autoFocus />
</label>
<DialogFooter className="border-t-0 pt-0">
<DialogClose asChild><button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={action.busy}>取消</button></DialogClose>
<button className={cn(buttonVariants())} disabled={action.busy}>确认重命名</button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>}
<ResourceState resource={tree} />
{tree.data?.length === 0 && <Empty>这个目录还是空的。上传文件或创建目录开始使用。</Empty>}
{!!tree.data?.length && <>
{/* Desktop table */}
<div className="hidden overflow-x-auto md:block">
<table className={tableClass}>
<thead><tr className={tableHeadRowClass}>
<th className={thClass}>名称</th>
<th className={thClass}>大小</th>
<th className={thClass}>修改时间</th>
<th className={cn(thClass, "text-right")}>操作</th>
</tr></thead>
<tbody>{tree.data.map(entry => {
const target = entry.path || join(entry.name);
return <tr key={entry.name} className={trClass}>
<td className="border-b border-border px-3 py-2.5">
{entry.is_dir ?
<button className="inline-flex items-center gap-1.5 text-left font-medium text-primary hover:underline" disabled={action.busy} onClick={() => go(target)}><Folder className="size-4 shrink-0" aria-hidden />{entry.name}</button> :
<span className="inline-flex items-center gap-1.5 text-muted-foreground"><File className="size-4 shrink-0" aria-hidden /><span className="text-foreground">{entry.name}</span></span>}
</td>
<td className="border-b border-border px-3 py-2.5 whitespace-nowrap">{entry.is_dir ? '目录' : bytes(entry.size)}</td>
<td className="border-b border-border px-3 py-2.5 whitespace-nowrap">{date(entry.mod_time)}</td>
<td className="border-b border-border px-3 py-2.5"><div className="flex flex-wrap items-center justify-end gap-1.5">{actionButtons(entry)}</div></td>
</tr>;
})}</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="grid gap-2 md:hidden">
{tree.data.map(entry => {
const target = entry.path || join(entry.name);
return <div key={entry.name} className="rounded-lg border border-border bg-card p-3">
<div className="mb-1 flex min-w-0 items-center gap-2">
{entry.is_dir ?
<button className="inline-flex min-w-0 flex-1 items-center gap-1.5 text-left font-medium text-primary" disabled={action.busy} onClick={() => go(target)}><Folder className="size-4 shrink-0" aria-hidden /><span className="truncate">{entry.name}</span></button> :
<span className="flex min-w-0 flex-1 items-center gap-1.5"><File className="size-4 shrink-0 text-muted-foreground" aria-hidden /><span className="truncate">{entry.name}</span></span>}
</div>
<p className="m-0 mb-2.5 text-xs text-muted-foreground">{entry.is_dir ? '目录' : bytes(entry.size)} · {date(entry.mod_time)}</p>
<div className="flex flex-wrap gap-1.5">{actionButtons(entry)}</div>
</div>;
})}
</div>
</>}
</Panel>
</>;
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { DialogRoot, DialogContent, DialogFooter } from './ui/dialog';
import { Input } from './ui/input';
import { buttonVariants } from './ui/button';
import { cn } from '../lib/utils';
export interface ConfirmOptions {
title: string;
body?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
/** When set, the confirm button stays disabled until this exact text is typed. */
requireText?: string;
requireTextLabel?: string;
}
interface ConfirmRequest {
options: ConfirmOptions;
resolve: (ok: boolean) => void;
}
export function ConfirmDialog({request, onClose}: {request: ConfirmRequest | undefined; onClose: (ok: boolean) => void}) {
const [typed, setTyped] = useState('');
useEffect(() => { setTyped(''); }, [request]);
const options = request?.options;
const needsText = !!options?.requireText;
const confirmed = !needsText || typed === options?.requireText;
return <DialogRoot open={!!request} onOpenChange={open => { if (!open) onClose(false); }}>
{options && <DialogContent title={options.title} onOpenAutoFocus={e => { if (!needsText) return; e.preventDefault(); }}>
{options.body && <div className="mb-4 text-[13px] leading-relaxed text-muted-foreground wrap-anywhere">{options.body}</div>}
{needsText && <label className="mb-1 grid gap-1.5 text-[13px] font-medium">
{options.requireTextLabel || '输入完整标识以确认'}
<Input value={typed} onChange={e => setTyped(e.target.value)} autoFocus autoComplete="off" spellCheck={false} className="font-mono" placeholder={options.requireText}/>
</label>}
<DialogFooter>
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} onClick={() => onClose(false)}>{options.cancelLabel || '取消'}</button>
<button type="button" disabled={!confirmed} className={cn(buttonVariants({variant: options.danger ? 'destructive' : 'default'}))} onClick={() => onClose(true)}>{options.confirmLabel || '确认'}</button>
</DialogFooter>
</DialogContent>}
</DialogRoot>;
}
/**
* Promise-based replacement for window.confirm / window.prompt(confirm-id).
* Render {confirm.element} once near the page root; ask() resolves true/false.
* Plain functions (no useCallback) keep it usable in bare vnode-tree tests.
*/
export function useConfirm() {
const [request, setRequest] = useState<ConfirmRequest>();
// A ref tracks the live request so back-to-back ask() calls (e.g. a fast
// double submit before re-render) cancel the previous promise instead of
// leaking it. Direct value sets keep bare vnode-tree tests working.
const current = useRef<ConfirmRequest | undefined>(undefined);
useEffect(() => () => {
current.current?.resolve(false);
current.current = undefined;
}, []);
const ask = (options: ConfirmOptions) => new Promise<boolean>(resolve => {
current.current?.resolve(false);
const next: ConfirmRequest = {options, resolve};
current.current = next;
setRequest(next);
});
const close = (ok: boolean) => {
current.current?.resolve(ok);
current.current = undefined;
setRequest(undefined);
};
return {ask, element: <ConfirmDialog request={request} onClose={close}/>};
}
+31
View File
@@ -0,0 +1,31 @@
import { cva, type VariantProps } from "class-variance-authority";
import { AlertCircle, Info, type LucideIcon } from "lucide-react";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const alertVariants = cva("flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-[13px] leading-relaxed [&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:shrink-0", {
variants: {
variant: {
destructive: "border-destructive/30 bg-destructive-muted text-destructive",
info: "border-primary/25 bg-secondary/60 text-secondary-foreground",
neutral: "border-border bg-muted/70 text-muted-foreground",
},
},
defaultVariants: { variant: "neutral" },
});
const icons: Record<string, LucideIcon> = { destructive: AlertCircle, info: Info, neutral: Info };
export interface AlertProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {
hideIcon?: boolean;
}
export function AlertBox({ className, variant = "neutral", hideIcon = false, children, ...props }: AlertProps) {
const Icon = icons[variant || "neutral"];
return (
<div role={variant === "destructive" ? "alert" : "status"} className={cn(alertVariants({ variant }), className)} {...props}>
{!hideIcon && <Icon aria-hidden />}
<div className="min-w-0 flex-1 wrap-anywhere">{children}</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap before:size-1.5 before:rounded-full before:bg-current",
{
variants: {
variant: {
default: "bg-muted text-muted-foreground",
success: "bg-success-muted text-success",
danger: "bg-destructive-muted text-destructive",
warning: "bg-warning-muted text-warning",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
+43
View File
@@ -0,0 +1,43 @@
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/70",
outline: "border border-border bg-card text-foreground shadow-sm hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"destructive-ghost": "text-destructive border border-border bg-card hover:bg-destructive-muted hover:border-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
// Pixel sizes remain correct with the application's 14px root font.
default: "h-[44px] md:h-[36px] px-4 text-sm",
sm: "h-[44px] md:h-[32px] px-3 text-[13px]",
lg: "h-[44px] px-5 text-[15px]",
icon: "h-[44px] w-[44px] md:h-[36px] md:w-[36px] p-0",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant, size, asChild = false, ...props },
ref,
) {
const Comp = asChild ? Slot : "button";
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
});
export { buttonVariants };
+26
View File
@@ -0,0 +1,26 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLElement>) {
return <section className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-card", className)} {...props} />;
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-wrap items-center justify-between gap-3 px-4 pt-4 pb-3 sm:px-5", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-[15px] font-semibold leading-none", className)} {...props} />;
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-[13px] text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-4 pb-4 sm:px-5 sm:pb-5", className)} {...props} />;
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center gap-2 border-t border-border px-4 py-3 sm:px-5", className)} {...props} />;
}
+56
View File
@@ -0,0 +1,56 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { useRef, type ComponentProps, type ReactNode } from "react";
import { cn } from "../../lib/utils";
export const DialogRoot = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({
className,
children,
title,
onOpenAutoFocus,
onCloseAutoFocus,
...props
}: ComponentProps<typeof DialogPrimitive.Content> & { title: string; children: ReactNode }) {
const opener = useRef<HTMLElement | null>(null);
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-nav/60 backdrop-blur-[2px] data-[state=open]:animate-[overlay-in_0.15s_ease-out]" />
<DialogPrimitive.Content
aria-describedby={undefined}
onOpenAutoFocus={event => {
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
onOpenAutoFocus?.(event);
}}
onCloseAutoFocus={event => {
onCloseAutoFocus?.(event);
if (!event.defaultPrevented && opener.current?.isConnected) {
event.preventDefault();
opener.current.focus();
}
}}
aria-busy={props["aria-busy"]}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[min(30rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-xl border border-border bg-card p-5 text-card-foreground shadow-dialog data-[state=open]:animate-[dialog-in_0.18s_ease-out]",
className,
)}
{...props}
>
<div className="mb-4 flex items-start justify-between gap-4">
<DialogPrimitive.Title className="text-base font-semibold wrap-anywhere">{title}</DialogPrimitive.Title>
<DialogPrimitive.Close disabled={props['aria-busy'] === true} className="grid size-[44px] shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring" aria-label="关闭对话框">
<X className="size-4" aria-hidden />
</DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
return <div className={cn("mt-5 flex flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end", className)} {...props} />;
}
+30
View File
@@ -0,0 +1,30 @@
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
import type { ComponentProps } from "react";
import { cn } from "../../lib/utils";
export const DropdownMenu = DropdownPrimitive.Root;
export const DropdownMenuTrigger = DropdownPrimitive.Trigger;
export function DropdownMenuContent({ className, ...props }: ComponentProps<typeof DropdownPrimitive.Content>) {
return (
<DropdownPrimitive.Portal>
<DropdownPrimitive.Content
sideOffset={6}
className={cn("z-50 min-w-44 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-dialog data-[state=open]:animate-[overlay-in_0.12s_ease-out]", className)}
{...props}
/>
</DropdownPrimitive.Portal>
);
}
export function DropdownMenuItem({ className, ...props }: ComponentProps<typeof DropdownPrimitive.Item>) {
return (
<DropdownPrimitive.Item
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2.5 sm:py-2 text-[13px] outline-none transition-colors data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type SelectHTMLAttributes, type CSSProperties } from "react";
import { cn } from "../../lib/utils";
const fieldClass =
"w-full min-w-0 rounded-md border border-input bg-card px-3 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:border-destructive aria-invalid:outline-destructive";
/** Class constants for raw <input>/<select>/<textarea> elements.
* Pages prefer these over the wrapper components so vnode-tree tests keep
* matching native element types. */
export const inputClass = cn(fieldClass, "h-[44px] md:h-[36px]");
export const textareaClass = cn(fieldClass, "py-2 min-h-20");
export const selectClass = cn(fieldClass, "h-[44px] md:h-[36px] appearance-none pr-8 bg-no-repeat bg-[right_0.6rem_center] bg-[length:14px]");
export const selectStyle: CSSProperties = {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23647d88' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")",
};
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input({ className, ...props }, ref) {
return <input ref={ref} className={cn(inputClass, className)} {...props} />;
});
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea({ className, ...props }, ref) {
return <textarea ref={ref} className={cn(textareaClass, className)} {...props} />;
});
/** Native select: keeps the OS picker on phones, styled to match inputs. */
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(function Select({ className, style, ...props }, ref) {
return <select ref={ref} className={cn(selectClass, className)} style={{...selectStyle, ...style}} {...props} />;
});
+10
View File
@@ -0,0 +1,10 @@
import type { LabelHTMLAttributes, ReactNode } from "react";
import { cn } from "../../lib/utils";
export function FieldLabel({ className, children, ...props }: LabelHTMLAttributes<HTMLLabelElement> & { children: ReactNode }) {
return (
<label className={cn("grid gap-1.5 text-[13px] font-medium text-foreground", className)} {...props}>
{children}
</label>
);
}
+30
View File
@@ -0,0 +1,30 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type { ComponentProps } from "react";
import { cn } from "../../lib/utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn("inline-flex w-full max-w-full gap-1 overflow-x-auto rounded-lg bg-muted p-1", className)}
{...props}
/>
);
}
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"shrink-0 cursor-pointer rounded-md px-3 py-2 sm:py-1.5 text-[13px] font-medium whitespace-nowrap text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn("mt-4 outline-none", className)} {...props} />;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+94 -34
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
vi.mock('react', () => ({
vi.mock('react', async importOriginal => ({
...await importOriginal<typeof import('react')>(),
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
useCallback(fn: unknown, deps: unknown[]) {
+61 -8
View File
@@ -1,7 +1,15 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { Inbox, RefreshCw } from 'lucide-react';
import { AlertBox } from './components/ui/alert';
import { Badge } from './components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from './components/ui/card';
import { buttonVariants } from './components/ui/button';
import { cn } from './lib/utils';
export const message = (error: unknown) => error instanceof Error ? error.message : '操作失败,请重试。';
export const bytes = (n?: number) => n === undefined ? '—' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KiB' : n < 1073741824 ? (n / 1048576).toFixed(1) + ' MiB' : (n / 1073741824).toFixed(2) + ' GiB';
export const date = (s?: string) => s ? new Date(s).toLocaleString('zh-CN') : '—';
export function useResource<T>(load: () => Promise<T>, key = '', interval = 0, shouldPoll: (data: T) => boolean = () => true, timeoutMs = 60_000) {
const loader = useRef(load); loader.current = load;
const polling = useRef(shouldPoll); polling.current = shouldPoll;
@@ -55,18 +63,63 @@ export function useResource<T>(load: () => Promise<T>, key = '', interval = 0, s
}, [loadNow, key, interval]);
return {data, error, loading, refresh};
}
export function Alert({children}: {children: ReactNode}) { return <div className="alert" role="alert">{children}</div>; }
export function Empty({children}: {children: ReactNode}) { return <div className="empty"><span>◇</span><p>{children}</p></div>; }
export function Status({value}: {value?: string}) { const labels: Record<string,string> = {running:'运行中', stopped:'已停止', exited:'已退出', created:'已创建', available:'可用', succeeded:'已完成', success:'已完成', failed:'失败', building:'构建中', queued:'排队中', validating:'验证中', missing:'缺失'}; return <span className={'status ' + (value || '')}>{labels[value || ''] || value || '未知'}</span>; }
export function Panel({title, action, children}: {title: string; action?: ReactNode; children: ReactNode}) { return <section className="panel"><div className="panel-head"><h2>{title}</h2>{action}</div>{children}</section>; }
export function ResourceState({resource}: {resource: {loading: boolean; error: string; refresh: () => Promise<void>}}) { return <>{resource.loading && <p className="muted" role="status">正在加载…</p>}{resource.error && <Alert>{resource.error} <button onClick={resource.refresh}>重试</button></Alert>}</>; }
/** Error banner. Same role as before: assertive, red. */
export function Alert({children}: {children: ReactNode}) { return <AlertBox variant="destructive">{children}</AlertBox>; }
export function Empty({children}: {children: ReactNode}) {
return <div className="grid justify-items-center gap-3 px-4 py-10 text-center text-muted-foreground">
<span className="grid size-12 place-items-center rounded-xl bg-secondary text-primary"><Inbox className="size-6" aria-hidden /></span>
<p className="m-0 max-w-md text-[13px] leading-relaxed">{children}</p>
</div>;
}
const statusVariant: Record<string, 'success' | 'danger' | 'warning'> = {
running: 'success', available: 'success', succeeded: 'success', success: 'success',
failed: 'danger', missing: 'danger',
queued: 'warning', building: 'warning', validating: 'warning',
};
export function Status({value}: {value?: string}) {
const labels: Record<string,string> = {running:'运行中', stopped:'已停止', exited:'已退出', created:'已创建', available:'可用', succeeded:'已完成', success:'已完成', failed:'失败', building:'构建中', queued:'排队中', validating:'验证中', missing:'缺失'};
return <Badge variant={statusVariant[value || ''] || 'default'}>{labels[value || ''] || value || '未知'}</Badge>;
}
export function Panel({title, action, children}: {title: string; action?: ReactNode; children: ReactNode}) {
return <Card className="mb-5">
<CardHeader><CardTitle>{title}</CardTitle>{action}</CardHeader>
<CardContent>{children}</CardContent>
</Card>;
}
export function ResourceState({resource}: {resource: {loading: boolean; error: string; refresh: () => Promise<void>}}) {
return <>
{resource.loading && <p className="m-0 mb-3 text-[13px] text-muted-foreground" role="status">正在加载…</p>}
{resource.error && <AlertBox variant="destructive" className="mb-3">{resource.error} <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'ml-2')} onClick={resource.refresh}>重试</button></AlertBox>}
</>;
}
/** Shared table chrome for raw <table> markup (kept as native elements for tests). */
export const tableClass = 'w-full border-collapse text-left text-[13px]';
export const tableHeadRowClass = '[&_th]:border-y [&_th]:border-border [&_th]:bg-muted/60';
export const thClass = 'px-3 py-2.5 text-xs font-medium text-muted-foreground';
export const tdClass = 'border-b border-border px-3 py-3 align-top';
export const trClass = 'transition-colors hover:bg-muted/40';
/** Icon-only refresh control for panel headers; polling already covers freshness. */
export function RefreshButton({loading, onClick, label = '刷新'}: {loading?: boolean; onClick: () => void; label?: string}) {
return <button type="button" title={label} aria-label={label} disabled={loading} onClick={onClick}
className={cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'text-muted-foreground')}>
<RefreshCw className={loading ? 'animate-spin' : undefined} aria-hidden />
</button>;
}
export function useAction() {
const [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
const lock = useRef(false);
async function run(action: () => Promise<unknown>, success = '操作已完成', confirm?: string) {
if (lock.current || (confirm && !window.confirm(confirm))) return false;
async function run(action: () => Promise<unknown>, success = '操作已完成') {
if (lock.current) return false;
lock.current = true; setBusy(true); setError(''); setNotice('');
try { await action(); setNotice(success); return true; } catch (e) { setError(message(e)); return false; } finally { lock.current = false; setBusy(false); }
}
return {busy, run, feedback: <>{error && <Alert>{error}</Alert>}{notice && <div className="notice" role="status">{notice}</div>}</>};
return {busy, run, feedback: <div className="grid gap-3 empty:hidden">{error && <Alert>{error}</Alert>}{notice && <AlertBox variant="info">{notice}</AlertBox>}</div>};
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
build: {
rollupOptions: {
output: {