Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.playwright-cli/
|
||||
output/
|
||||
@@ -0,0 +1,11 @@
|
||||
# 前后端协调
|
||||
|
||||
前端 agent 当前工具没有 actor/send,无法直接发送给 general-2。请后端在根目录 API.md 记录实际接口、鉴权/CSRF、列表 envelope、实例/profile/备份/升级、文件上传和管理员字段。前端将以 API.md 校对实现,不模拟成功。
|
||||
|
||||
Vite 开发监听 127.0.0.1,代理默认 http://127.0.0.1:8088,生产构建 web/dist,同源 /api 和 /dsh。
|
||||
|
||||
已完成中文页面与首次构建,当前待确认的响应字段:images 是 id 还是 image_id;builds 日志字段 logs;ws/tree 是否直接数组及 is_dir/mod_time;usage used_bytes/quota_bytes;audit user_id/created_at;backups id/size/created_at。请明确空列表返回 [] 或 null。恢复/下载备份/升级接口尚未列出,UI 将明确提示缺口而不猜测接口。
|
||||
|
||||
11:24 检查 internal/api/api.go:创建实例入参目前 struct{ Name, ImageID, Version string } 无 json 标签,image_id 不会映射到 ImageID,需后端补标签。生命周期仍只是写 start/stop 字符串而非真实 Docker 操作,前端不会把 start 字符串映射成 running。请务必真实实现或返回明确错误,避免假成功。
|
||||
|
||||
验证:npm test 六项全部通过;npm run build 通过。Playwright 真浏览器已验证桌面/390px 移动登录界面、离线登录错误,移动 documentWidth=390 无横向溢出。8088 暂未启动,无法验证登录后页面;请父 agent 启动后端后通知可用临时验证账号,避免操作共享数据。
|
||||
@@ -0,0 +1,26 @@
|
||||
# DSH 中文工作台
|
||||
|
||||
React 19 + TypeScript + Vite;普通 CSS 响应式布局,没有运行时演示数据。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
使用 Node.js 22+:
|
||||
|
||||
~~~bash
|
||||
npm ci
|
||||
npm run dev
|
||||
npm test
|
||||
npm run build
|
||||
~~~
|
||||
|
||||
开发服务只监听 127.0.0.1:5173,/api 和 /dsh 代理至 127.0.0.1:8088。生产产物为 dist/,由后端同源提供;不能直接通过 file:// 使用。
|
||||
|
||||
## API 与安全
|
||||
|
||||
- 根目录 API.md 是接口契约;API 客户端集中在 src/api.ts,可注入 fetch 进行独立单元测试。
|
||||
- HttpOnly Cookie 登录;CSRF token 仅留在内存,刷新通过 /api/me 重新获取。密码、token 不写入 localStorage。
|
||||
- 操作失败保留错误,不模拟成功;删除、重启、构建等操作二次确认。
|
||||
- 备份恢复和升级等待后端契约,当前明确显示不可用说明,不发送猜测接口。
|
||||
- 单元测试里的 fetch 替身仅用于测试,不打包进生产页面。
|
||||
|
||||
当前后端仍在实现部分端点,页面会真实显示其 HTTP 错误。请不要把前端入口存在等同于后端能力已交付。
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" content="#152b36"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+2340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "dsh-web-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"monaco-editor": "^0.52.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#172e39"/><path d="M17 16h13c24 0 24 32 0 32H17zm9 8v16h5c12 0 12-16 0-16z" fill="#b8e6dc"/><circle cx="49" cy="16" r="5" fill="#54c7ae"/></svg>
|
||||
|
After Width: | Height: | Size: 248 B |
@@ -0,0 +1,17 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { QuotaDialog } from './QuotaDialog';
|
||||
import { quotaBytes } from './quota';
|
||||
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';
|
||||
export function Admin() {
|
||||
return <><AdminAccounts/><Orphans/></>;
|
||||
}
|
||||
function AdminAccounts() {
|
||||
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></>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, ApiError, type User } from './api';
|
||||
import { Alert, message, Panel, useAction } from './ui';
|
||||
import { Instances } from './Instances';
|
||||
import { Images } from './Images';
|
||||
import { RuntimeSelector } from './RuntimeSelector';
|
||||
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:'账号管理与操作追踪'}];
|
||||
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('密码已修改,请使用新密码重新登录。');}}/>;
|
||||
}
|
||||
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>
|
||||
</>}
|
||||
</div><footer>DSH 工作台 <span>数据与操作以服务器实际结果为准</span></footer></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>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { api, instancePath, type Instance, type Backup } from './api';
|
||||
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
|
||||
|
||||
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>
|
||||
<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>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { PasswordDialog } from './PasswordDialog';
|
||||
|
||||
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();}}/>}</>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { api, type Image, type Instance } from './api';
|
||||
import { ResourceState, useAction, useResource } from './ui';
|
||||
|
||||
export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise<void>}) {
|
||||
const images = useResource(api.images), action = useAction();
|
||||
const [mode, setMode] = useState('local'), [progress, setProgress] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]);
|
||||
useEffect(() => {let live=true; const load=()=>api.request<typeof intents>('/intents').then(v=>{if(live)setIntents(v);}).catch(e=>{if(live)setProgress(String(e));}); void load(); const timer=window.setInterval(load,2000); return ()=>{live=false;window.clearInterval(timer);};}, []);
|
||||
const active = useRef(true);
|
||||
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
|
||||
const available = images.data?.filter(i => !i.status || i.status === 'available') || [];
|
||||
async function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget), name = String(form.get('name'));
|
||||
await action.run(async () => {
|
||||
let image: Image | undefined = available.find(i => (i.image_id || i.id) === form.get('image'));
|
||||
if (mode === 'latest') {
|
||||
if (!version) {
|
||||
const latest = await api.request<{version:string}>('/latest');
|
||||
if (!latest.version) throw new Error('服务器未返回精确版本。');
|
||||
setVersion(latest.version); return;
|
||||
}
|
||||
const intent = await api.request<{id:string}>('/intents', 'POST', {name, version});
|
||||
if (!intent.id) throw new Error('未收到意图确认,请刷新核实,勿重复提交。');
|
||||
setIntents(await api.request<typeof intents>('/intents'));
|
||||
await refresh(); return;
|
||||
}
|
||||
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, version:image.version});
|
||||
if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。');
|
||||
if (active.current) {await refresh(); close();}
|
||||
});
|
||||
if (active.current) setProgress('');
|
||||
}
|
||||
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}<p className="callout">确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。</p><fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label><label>创建方式<select value={mode} onChange={e => {setMode(e.target.value); setVersion('');}}><option value="local">选择本地镜像</option><option value="latest">构建最新版并创建</option></select></label>{mode === 'local' && <><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>}</>}{mode === 'latest' && version && <p>将锁定精确版本 <strong>{version}</strong>,确认后预留名额并构建创建。</p>}<button className="primary" disabled={mode === 'local' && !available.length}>{mode === 'local' ? '确认创建' : version ? '确认版本并预留创建' : '查询最新版'}</button></fieldset>{progress && <p role="status">{progress}</p>}<ul>{intents.map(i => <li key={i.id}>{i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && <button type="button" disabled={action.busy} onClick={() => void action.run(async () => {await api.request('/intents/' + i.id, 'DELETE'); setIntents(await api.request<typeof intents>('/intents')); await refresh();})}>取消等待并释放名额</button>}{i.status === 'success' && <button type="button" onClick={() => void refresh()}>刷新实例列表</button>}</li>)}</ul><button type="button" onClick={close}>关闭(保留已提交意图)</button></form>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { api } from './api';
|
||||
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
|
||||
export function Images({admin = false}: {admin?:boolean}) {
|
||||
const images = useResource(api.images), builds = useResource(api.builds, '', 5000); const action = useAction();
|
||||
const [latest, setLatest] = useState<{version:string; checked_at?:string}>(), [version,setVersion] = useState(''), [selected,setSelected] = useState<string>();
|
||||
function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {await api.request('/builds','POST',{version}); await builds.refresh();}, '构建任务已提交,请查看任务的实际状态。', '确认在本机手动构建精确版本 ' + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
|
||||
const current = builds.data?.find(b => b.id === selected);
|
||||
return <>{action.feedback}<Panel title="查询与手动构建"><div className="split"><div><p className="muted">从 npm 查询 dist-tags.latest,仅查询,不自动构建。</p><button disabled={action.busy} onClick={() => action.run(async () => {const result = await api.request<{version:string; checked_at?:string}>('/latest'); setLatest(result); setVersion(result.version);}, '最新版本查询完成')}>查询 latest</button>{latest && <p><strong>{latest.version}</strong> <small>查询时间 {date(latest.checked_at)}</small></p>}</div><form className="build-form" onSubmit={build}><label>精确 DSH 版本<input required value={version} onChange={e => setVersion(e.target.value)} placeholder="查询 latest,或填写精确版本" pattern="[0-9]+\.[0-9]+\.[0-9]+.*" /></label><button className="primary" disabled={action.busy || !version}>手动构建</button></form></div><p className="callout">镜像仅保存在本机。构建成功不等于实例升级;创建或升级前请检查可用状态。</p></Panel><Panel title="本地镜像" action={<button disabled={images.loading} onClick={images.refresh}>刷新</button>}><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}/></td><td>{i.usage_count ?? 0} 个实例 {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>}><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>{builds.data.map(b => <tr key={b.id}><td className="mono">{b.id}</td><td>{b.version}</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>}{current && <div><h3>{current.version} · 构建日志</h3>{current.error && <p className="alert">{current.error}</p>}<pre className="logs">{current.logs || '任务尚未输出日志'}</pre></div>}</Panel></>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { ProfileEditor } from './ProfileEditor';
|
||||
import { BackupPanel } from './BackupPanel';
|
||||
import { CreateInstance } from './CreateInstance';
|
||||
import { api, instancePath, type Instance } from './api';
|
||||
import { Panel, ResourceState, Empty, Status, bytes, date, useAction, useResource } from './ui';
|
||||
export function Instances() {
|
||||
const instances = useResource(api.instances, '', 10000), usage = useResource(api.usage);
|
||||
const action = useAction(); const [creating, setCreating] = useState(false), [selected, setSelected] = useState<Instance>();
|
||||
function operate(i: Instance, operation: string) { void action.run(async () => {await api.request(instancePath(i.id) + (operation === 'delete' ? '' : '/' + operation), operation === 'delete' ? 'DELETE' : 'POST'); if (selected?.id === i.id && operation === 'delete') setSelected(undefined); await instances.refresh();}, '服务器已完成操作', operation === 'delete' ? '确认删除实例「' + i.name + '」?此操作不可撤销。' : operation !== 'start' ? '确认停止或重启「' + i.name + '」?当前连接将中断。' : undefined); }
|
||||
return <><div className="stats"><div><span>我的实例</span><strong>{instances.data ? instances.data.length : '—'}<small> / 2</small></strong><p>每个账号最多两个实例</p></div><div><span>运行中</span><strong>{instances.data ? instances.data.filter(i => i.status === 'running').length : '—'}</strong><p>独立的 DSH 运行环境</p></div><div><span>工作区用量</span><strong className="size-value">{bytes(usage.data?.used_bytes)}</strong><p>配额 {bytes(usage.data?.quota_bytes)}</p></div></div><ResourceState resource={usage} />{action.feedback}<Panel title="实例列表" action={<div className="actions"><button onClick={instances.refresh} disabled={instances.loading}>刷新</button><button className="primary" onClick={() => setCreating(!creating)} disabled={!instances.data || instances.data.length >= 2}>{creating ? '取消' : '+ 创建实例'}</button></div>}><ResourceState resource={instances} />{creating && <CreateInstance close={() => setCreating(false)} refresh={instances.refresh}/>}{instances.data?.length === 0 && <Empty>还没有实例。选择本地镜像,创建你的第一个开发环境。</Empty>}{!!instances.data?.length && <div className="table-scroll"><table><thead><tr><th>实例</th><th>版本</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead><tbody>{instances.data.map(i => <tr key={i.id}><td><strong>{i.name}</strong><small className="mono">{i.id}</small></td><td>{i.version}</td><td><Status value={i.status}/></td><td>{date(i.created_at)}</td><td><div className="actions">{i.status === 'running' ? <><a className="button" href={'/dsh/' + encodeURIComponent(i.id) + '/'} target="_blank" rel="noreferrer">打开 DSH ↗</a><button disabled={action.busy} onClick={() => operate(i,'stop')}>停止</button></> : <button disabled={action.busy} onClick={() => operate(i,'start')}>启动</button>}<button onClick={() => setSelected(i)}>管理</button><button className="danger" disabled={action.busy} onClick={() => operate(i,'delete')}>删除</button></div></td></tr>)}</tbody></table></div>}</Panel>{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}</>;
|
||||
}
|
||||
function InstanceDetail({instance, close, refresh}: {instance: Instance; close: () => void; refresh: () => Promise<void>}) {
|
||||
const [tab, setTab] = useState('profile'); const [content, setContent] = useState<string>(); const action = useAction();
|
||||
const base = instancePath(instance.id);
|
||||
const stopped = ['stopped', 'exited', 'created'].includes(instance.status);
|
||||
const images = useResource(api.images);
|
||||
const available = images.data?.filter(i => !i.status || i.status === 'available') || [];
|
||||
const [upgradeImage, setUpgradeImage] = useState('');
|
||||
const [upgradePassword, setUpgradePassword] = useState('');
|
||||
const profile = useResource(() => api.request<{content:string}>(base + '/profile'));
|
||||
const preview = useResource(() => api.request<{bundles:unknown;config:string;preview_error?:string}>(base + '/profile-preview'));
|
||||
const logs = useResource(() => api.request<{logs:string}>(base + '/logs'));
|
||||
return <Panel title={instance.name + ' · 实例管理'} action={<button onClick={close}>关闭</button>}><div className="tabs">{[['profile','Profile 配置'],['preview','配置预览'],['backups','备份与恢复'],['upgrade','版本升级'],['logs','运行日志']].map(([id,label]) => <button className={tab === id ? 'active' : ''} onClick={() => setTab(id)} key={id}>{label}</button>)}</div>{action.feedback}{tab === 'profile' && <div className="callout">Profile 写入前需先停止实例。当前状态:<Status value={instance.status}/>{!stopped && <button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/stop','POST'); await refresh();}, '停止请求已完成,请确认实例状态。', '停止将中断当前连接,是否继续?')}>停止实例</button>}</div>}{tab === 'profile' && <><p className="muted">profiles/web/cordis.patch.yml · 停止实例后保存,保存后自动启动验证。密钥原文不在此展示。</p><ResourceState resource={profile} />{profile.data && <><label>YAML 配置<ProfileEditor value={content ?? profile.data.content} onChange={setContent} readOnly={action.busy}/></label><div className="actions"><button className="primary" disabled={action.busy || !stopped || content === undefined} onClick={() => action.run(async () => {await api.request(base + '/profile','PUT',{content,restart:true}); await profile.refresh(); setContent(undefined); await refresh();}, '配置已保存并启动验证。', '确认覆盖当前 Profile 配置?')}>保存并启动</button><button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/restart','POST'); await refresh();}, '实例已重启', '重启将中断当前连接,是否继续?')}>重启实例</button></div></>}</>}{tab === 'preview' && <><button onClick={preview.refresh}>刷新预览</button><ResourceState resource={preview}/>{preview.data && <><h3>Bundle 清单</h3><pre>{JSON.stringify(preview.data.bundles,null,2)}</pre>{preview.data.preview_error && <p role="status">{preview.data.preview_error}</p>}<ProfileEditor value={preview.data.config} readOnly label="配置预览(字符串已脱敏)"/></>}</>}{tab === 'backups' && <BackupPanel instance={instance} refresh={async () => {await profile.refresh(); setContent(undefined); await refresh();}}/>}{tab === 'upgrade' && <form className="inline-form" onSubmit={e => {e.preventDefault(); if (!available.some(i => (i.image_id || i.id) === upgradeImage)) return; void action.run(async () => {try {await api.request(base + '/upgrade','POST',{image_id:upgradeImage,password:upgradePassword}); await refresh();} finally {setUpgradePassword('');}}, '实例升级请求已完成。', '确认使用所选本地镜像升级?建议先备份配置。');}}><p>当前版本:{instance.version}</p><label>当前登录密码(加密升级前备份)<input type="password" required autoComplete="current-password" value={upgradePassword} onChange={e => setUpgradePassword(e.target.value)}/></label><ResourceState resource={images}/><label>本地镜像<select required value={upgradeImage} onChange={e => setUpgradeImage(e.target.value)}><option value="" disabled>选择可用镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label><button className="primary" disabled={action.busy || !upgradeImage}>升级实例</button></form>}{tab === 'logs' && <><button onClick={logs.refresh} disabled={logs.loading}>刷新日志</button><ResourceState resource={logs}/><pre className="logs">{logs.data?.logs || '暂无日志'}</pre></>}</Panel>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { api } from './api';
|
||||
import { Panel, ResourceState, useAction, useResource } from './ui';
|
||||
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='+id+'&confirm='+id,'DELETE');await list.refresh();},'容器已回收,数据已保留','确认回收此停止的孤儿容器?')}>回收</button></div>)}
|
||||
</Panel>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useId, useState, type FormEvent } from 'react';
|
||||
import { Dialog } from './Dialog';
|
||||
import { PasswordField } from './PasswordField';
|
||||
import { passwordResetError } from './passwordReset';
|
||||
import { useAction } from './ui';
|
||||
|
||||
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('');
|
||||
const action = useAction(), hintId = useId(), error = passwordResetError(password, confirmation);
|
||||
const currentError = currentRequired && new TextEncoder().encode(current).length > 72 ? '当前密码不能超过 72 UTF-8 字节。' : '';
|
||||
const valid = !error && !currentError && (!currentRequired || !!current);
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!valid || action.busy) return;
|
||||
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>
|
||||
</form></Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
|
||||
// Vite emits this worker locally; no AMD loader or CDN is involved.
|
||||
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
|
||||
|
||||
export interface ProfileEditorProps {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ProfileEditor({ value, onChange, readOnly = false, label = 'YAML 配置' }: ProfileEditorProps) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const change = useRef(onChange);
|
||||
change.current = onChange;
|
||||
const initialValue = useRef(value);
|
||||
const updating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const model = monaco.editor.createModel(initialValue.current, 'yaml');
|
||||
const instance = monaco.editor.create(host.current, {
|
||||
model, automaticLayout: true, minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
|
||||
wordWrap: 'on', ariaLabel: label, readOnly,
|
||||
});
|
||||
editor.current = instance;
|
||||
const listener = instance.onDidChangeModelContent(() => {
|
||||
if (!updating.current) change.current?.(instance.getValue());
|
||||
});
|
||||
return () => { listener.dispose(); instance.dispose(); model.dispose(); editor.current = null; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const instance = editor.current;
|
||||
if (instance && instance.getValue() !== value) {
|
||||
updating.current = true;
|
||||
instance.setValue(value);
|
||||
updating.current = 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' }} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { api, type User } from './api';
|
||||
import { Dialog } from './Dialog';
|
||||
import { bytes, useAction } from './ui';
|
||||
import { GiB, quotaBytes } from './quota';
|
||||
|
||||
export function QuotaDialog({user, onClose, onSaved}: {user: User; onClose: () => void; onSaved: () => void}) {
|
||||
const [value, setValue] = useState(String(user.quota_bytes / GiB));
|
||||
const action = useAction(), next = quotaBytes(value), valid = next !== null && next !== user.quota_bytes;
|
||||
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();});
|
||||
}
|
||||
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>
|
||||
</form></Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { api, type User } from './api';
|
||||
import { PasswordDialog } from './PasswordDialog';
|
||||
|
||||
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();
|
||||
}}/> }</>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { runtimeReturnTarget } from './RuntimeRenew';
|
||||
const hash = (target: string) => '#runtime-renew?return=' + encodeURIComponent(target);
|
||||
it('only permits local runtime return paths', () => {
|
||||
expect(runtimeReturnTarget(hash('/dsh/demo/'))).toBe('/dsh/demo/');
|
||||
expect(runtimeReturnTarget(hash('/dsh/demo?x=1&y=2'))).toBe('/dsh/demo?x=1&y=2');
|
||||
for (const target of ['https://evil.test', '//evil.test/dsh/demo', '/api/logout', '/dsh/demo/../../api/logout', '/dsh/demo/%2e%2e/other', '/dsh/demo/\\evil', '/dsh/']) {
|
||||
expect(runtimeReturnTarget(hash(target))).toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
|
||||
export function runtimeReturnTarget(hash: string): string | null {
|
||||
const target = new URLSearchParams(hash.slice('#runtime-renew?'.length)).get('return');
|
||||
if (!target || !/^\/dsh\/[a-zA-Z0-9_-]+(?:\/|\?|$)/.test(target)) return null;
|
||||
const path = target.split('?')[0];
|
||||
if (/[\\%\x00-\x20\x7f]/.test(path) || path.split('/').some(p => p === '.' || p === '..')) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
export function RuntimeRenew() {
|
||||
const [error, setError] = useState('');
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const target = runtimeReturnTarget(window.location.hash);
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
let active = true;
|
||||
setError('');
|
||||
api.me().then(() => { if (active) window.location.replace(target); }).catch(() => {
|
||||
if (active) setError('无法续期。请重试,或返回面板登录后重新打开实例。');
|
||||
});
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { useRuntimeInstances } from './RuntimeWorkspace';
|
||||
|
||||
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)}>
|
||||
{!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>;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, type Instance } from './api';
|
||||
import { Alert, message, Status } from './ui';
|
||||
import { RuntimeSelector } from './RuntimeSelector';
|
||||
import { runtimeSelectionKey, selectRuntime } from './runtimeSelection';
|
||||
|
||||
export function useRuntimeInstances(userId: string, page: string) {
|
||||
const [instances, setInstances] = useState<Instance[]>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(() => {
|
||||
try { return localStorage.getItem(runtimeSelectionKey(userId)); } catch { return null; }
|
||||
});
|
||||
const selection = useRef(selectedId);
|
||||
const refreshRef = useRef<() => Promise<void>>(async () => {});
|
||||
const select = useCallback((id: string | null) => {
|
||||
selection.current = id;
|
||||
setSelectedId(id);
|
||||
try {
|
||||
if (id) localStorage.setItem(runtimeSelectionKey(userId), id);
|
||||
else localStorage.removeItem(runtimeSelectionKey(userId));
|
||||
} catch { /* Selection remains usable when browser storage is unavailable. */ }
|
||||
}, [userId]);
|
||||
const refresh = useCallback(() => refreshRef.current(), []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let pending: Promise<void> | undefined;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const load = (): Promise<void> => {
|
||||
if (!active) return Promise.resolve();
|
||||
if (pending) return pending;
|
||||
clearTimeout(timer);
|
||||
setLoading(true);
|
||||
pending = api.instances().then(data => {
|
||||
if (!active) return;
|
||||
const list = data ?? [];
|
||||
setInstances(list);
|
||||
select(selectRuntime(list, selection.current));
|
||||
setError('');
|
||||
}).catch(e => { if (active) setError(message(e)); }).finally(() => {
|
||||
pending = undefined;
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
timer = setTimeout(() => { void load(); }, 15000);
|
||||
}
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
refreshRef.current = load;
|
||||
void load();
|
||||
return () => { active = false; clearTimeout(timer); };
|
||||
}, [select]);
|
||||
useEffect(() => {
|
||||
if (page !== 'dsh' && page !== 'settings' && page !== 'workspace') return;
|
||||
void refresh();
|
||||
const focused = () => { if (document.visibilityState === 'visible') void refresh(); };
|
||||
window.addEventListener('focus', focused);
|
||||
document.addEventListener('visibilitychange', focused);
|
||||
return () => {
|
||||
window.removeEventListener('focus', focused);
|
||||
document.removeEventListener('visibilitychange', focused);
|
||||
};
|
||||
}, [page, refresh]);
|
||||
return { instances, loading, error, selectedId, select, refresh };
|
||||
}
|
||||
|
||||
export function RuntimeWorkspace({ runtime, visible, manage, settings }: {
|
||||
runtime: ReturnType<typeof useRuntimeInstances>; visible: boolean; manage: () => void; settings: () => void;
|
||||
}) {
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [frameError, setFrameError] = useState(false);
|
||||
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); }
|
||||
}}/></>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { api } from './api';
|
||||
import { Alert, Panel, ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
|
||||
import { RuntimeSelector } from './RuntimeSelector';
|
||||
import type { useRuntimeInstances } from './RuntimeWorkspace';
|
||||
import { displayFilePath, fileScopeKey, joinFilePath, navigateDirectory } from './workspaceLocation';
|
||||
|
||||
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>}
|
||||
{runtime.error && <Alert>{runtime.error}</Alert>}
|
||||
</>;
|
||||
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}/>;
|
||||
}
|
||||
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}/>;
|
||||
}
|
||||
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 go = (target: string) => setPath(navigateDirectory(path, target, action.busy));
|
||||
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></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();}, '文件已上传'); 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();}, '重命名已完成', '确认重命名此文件或目录?');}}><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)}>下载</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();}, '已删除', '确认永久删除「' + entry.name + '」' + (entry.is_dir ? '(仅允许空目录)' : '') + '?可能包含敏感配置,删除无法撤销。')}>删除</button></div></td></tr>;})}</tbody></table></div>}</Panel></>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createApi, ApiError, instancePath } from './api';
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {status,headers:{'Content-Type':'application/json'}});
|
||||
describe('API client', () => {
|
||||
it('sends account mutations with exact payloads and CSRF', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'token'})).mockImplementation(async () => json({ok:true}));
|
||||
const api = createApi(fetcher); await api.me();
|
||||
const mutations = [
|
||||
['/users', 'PATCH', {id:'u',quota_bytes:5 * 1073741824}],
|
||||
['/me/password', 'POST', {current_password:'old-password',password:'new-password'}],
|
||||
['/users/reset-password', 'POST', {id:'u',password:'new-password'}],
|
||||
] as const;
|
||||
for (const [path, method, body] of mutations) await api.request(path, method, body);
|
||||
mutations.forEach(([path, method, body], index) => {
|
||||
const [url, options] = fetcher.mock.calls[index + 1];
|
||||
expect(url).toBe('/api' + path); expect(options?.method).toBe(method);
|
||||
expect(JSON.parse(options?.body as string)).toEqual(body);
|
||||
expect(new Headers(options?.headers).get('X-CSRF-Token')).toBe('token');
|
||||
});
|
||||
});
|
||||
it.each(['实例/a &?#', undefined])('scopes every workspace operation to %s', async instance => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => json({ok:true}));
|
||||
const api = createApi(fetcher);
|
||||
const path = '目录/a &?#', next = '目录/新名称';
|
||||
const file = new File(['hello'], 'test.txt');
|
||||
const query = instance ? '?instance=' + encodeURIComponent(instance) : '';
|
||||
const suffix = instance ? '&instance=' + encodeURIComponent(instance) : '';
|
||||
await api.usage(instance);
|
||||
await api.tree(path, instance);
|
||||
await api.upload(path, file, instance);
|
||||
await api.mkdir(path, instance);
|
||||
await api.rename(path, next, instance);
|
||||
await api.delete(path, instance);
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/ws/usage' + query,
|
||||
'/api/ws/tree?path=' + encodeURIComponent(path) + suffix,
|
||||
'/api/ws/upload' + query,
|
||||
'/api/ws/mkdir' + query,
|
||||
'/api/ws/rename' + query,
|
||||
'/api/ws/delete?path=' + encodeURIComponent(path) + suffix,
|
||||
]);
|
||||
expect(api.download(path, instance)).toBe('/api/ws/download?path=' + encodeURIComponent(path) + suffix);
|
||||
expect(fetcher.mock.calls.map(([, options]) => options?.method)).toEqual(['GET','GET','POST','POST','POST','DELETE']);
|
||||
const upload = fetcher.mock.calls[2][1]!;
|
||||
expect((upload.body as FormData).get('path')).toBe(path);
|
||||
expect((upload.body as FormData).get('file')).toEqual(file);
|
||||
expect(new Headers(upload.headers).has('Content-Type')).toBe(false);
|
||||
expect(JSON.parse(fetcher.mock.calls[3][1]?.body as string)).toEqual({path});
|
||||
expect(JSON.parse(fetcher.mock.calls[4][1]?.body as string)).toEqual({path, new_path:next});
|
||||
});
|
||||
it('does not report session expiration on initial anonymous session or bad login', async () => {
|
||||
const dispatchEvent = vi.fn();
|
||||
vi.stubGlobal('window', {dispatchEvent});
|
||||
try {
|
||||
const api = createApi(vi.fn<typeof fetch>().mockImplementation(async () => json({error:'unauthorized'},401)));
|
||||
await expect(api.me()).rejects.toMatchObject({status:401});
|
||||
await expect(api.login('member','incorrect')).rejects.toMatchObject({status:401});
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
await expect(api.instances()).rejects.toMatchObject({status:401});
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent.mock.calls[0][0].type).toBe('session-expired');
|
||||
} finally {vi.unstubAllGlobals();}
|
||||
});
|
||||
it('uses same-origin sessions and attaches CSRF only to writes', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'secret'})).mockResolvedValueOnce(json([])).mockResolvedValueOnce(json({ok:true}));
|
||||
const api = createApi(fetcher); await api.login('member','password'); await api.instances(); await api.request('/instances','POST',{name:'中文'});
|
||||
expect(fetcher.mock.calls[0][0]).toBe('/api/login');
|
||||
expect(fetcher.mock.calls[0][1]?.credentials).toBe('same-origin');
|
||||
expect(new Headers(fetcher.mock.calls[1][1]?.headers).has('X-CSRF-Token')).toBe(false);
|
||||
expect(new Headers(fetcher.mock.calls[2][1]?.headers).get('X-CSRF-Token')).toBe('secret');
|
||||
expect(JSON.parse(fetcher.mock.calls[2][1]?.body as string)).toEqual({name:'中文'});
|
||||
});
|
||||
it('restores CSRF after page reload and clears it after logout', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'restored'})).mockImplementation(async () => json({ok:true}));
|
||||
const api = createApi(fetcher); await api.me(); await api.logout(); await api.request('/users','POST',{});
|
||||
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-CSRF-Token')).toBe('restored');
|
||||
expect(new Headers(fetcher.mock.calls[2][1]?.headers).has('X-CSRF-Token')).toBe(false);
|
||||
});
|
||||
it('returns server failures rather than simulating success', async () => {
|
||||
const api = createApi(vi.fn<typeof fetch>().mockResolvedValue(json({error:'quota exceeded'},413)));
|
||||
await expect(api.request('/ws/upload','POST')).rejects.toMatchObject({status:413,message:'quota exceeded'});
|
||||
});
|
||||
it('reports network and malformed-success responses', async () => {
|
||||
await expect(createApi(vi.fn<typeof fetch>().mockRejectedValue(new TypeError())).me()).rejects.toMatchObject({status:0});
|
||||
await expect(createApi(vi.fn<typeof fetch>().mockResolvedValue(new Response('<html>SPA</html>'))).me()).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
it('supports empty successful responses', async () => {
|
||||
expect(await createApi(vi.fn<typeof fetch>().mockResolvedValue(new Response(null,{status:204}))).request('/logout','POST')).toBeUndefined();
|
||||
});
|
||||
it('encodes paths and lets browser supply multipart boundary', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => json({ok:true})); const api = createApi(fetcher);
|
||||
await api.tree('目录/a & b');
|
||||
expect(fetcher.mock.calls[0][0]).toBe('/api/ws/tree?path=' + encodeURIComponent('目录/a & b'));
|
||||
await api.upload('目录',new File(['hello'],'test.txt'));
|
||||
const options = fetcher.mock.calls[1][1]!;
|
||||
expect(options.body).toBeInstanceOf(FormData);
|
||||
expect(new Headers(options.headers).has('Content-Type')).toBe(false);
|
||||
expect((options.body as FormData).get('path')).toBe('目录');
|
||||
expect(api.download('a?#')).toBe('/api/ws/download?path=a%3F%23');
|
||||
expect(instancePath('a/b')).toBe('/instances/a%2Fb');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
}
|
||||
export interface User { id: string; username: string; admin: boolean; quota_bytes: number; created_at?: string }
|
||||
export interface Session { user: User; csrf_token: string }
|
||||
export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string }
|
||||
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
|
||||
export interface Build { id: string; version: string; status: string; image_id?: string; logs?: string; error?: string; created_at: string }
|
||||
export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string }
|
||||
export interface Usage { used_bytes: number; quota_bytes: number }
|
||||
export interface Backup { id: string; created_at: string; size: number }
|
||||
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
||||
export function createApi(fetcher: typeof fetch = fetch) {
|
||||
let csrf = '';
|
||||
let refreshing: Promise<Session> | undefined;
|
||||
const csrfCookie = () => typeof document === 'undefined' ? '' : document.cookie.split('; ').find(c => c.startsWith('dsh_csrf='))?.slice(9) || '';
|
||||
async function request<T>(path: string, method = 'GET', data?: unknown, retried = false): Promise<T> {
|
||||
const headers = new Headers();
|
||||
if (method !== 'GET' && (csrf || csrfCookie())) headers.set('X-CSRF-Token', csrf || csrfCookie());
|
||||
const multipart = data instanceof FormData;
|
||||
if (data !== undefined && !multipart) headers.set('Content-Type', 'application/json');
|
||||
let response: Response;
|
||||
try { response = await fetcher('/api' + path, { method, credentials: 'same-origin', headers, body: data === undefined ? undefined : multipart ? data : JSON.stringify(data) }); }
|
||||
catch { throw new ApiError(0, '无法连接服务器,请检查网络或后端服务。'); }
|
||||
if (response.status === 401 && !retried && path !== '/login' && path !== '/refresh' && (csrf || csrfCookie())) {
|
||||
try {
|
||||
refreshing ??= request<Session>('/refresh', 'POST', undefined, true).finally(() => { refreshing = undefined; });
|
||||
const session = await refreshing; csrf = session.csrf_token;
|
||||
return request<T>(path, method, data, true);
|
||||
} catch { /* The original response determines session expiration handling. */ }
|
||||
}
|
||||
const text = await response.text();
|
||||
let payload: unknown;
|
||||
try { payload = text ? JSON.parse(text) : undefined; } catch { payload = undefined; }
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && path !== '/login' && path !== '/me' && path !== '/refresh' && typeof window !== 'undefined') window.dispatchEvent(new Event('session-expired'));
|
||||
throw new ApiError(response.status, (payload as {error?: string})?.error || (response.status === 401 ? '登录已失效,请重新登录。' : '请求失败(HTTP ' + response.status + ')'));
|
||||
}
|
||||
if (text && payload === undefined) throw new ApiError(response.status, '服务器返回了非 JSON 响应,请检查 API 代理配置。');
|
||||
return payload as T;
|
||||
}
|
||||
return {
|
||||
request,
|
||||
async me() { const session = await request<Session>('/me'); csrf = session.csrf_token; return session; },
|
||||
async login(username: string, password: string) { const session = await request<Session>('/login', 'POST', {username,password}); csrf = session.csrf_token; return session; },
|
||||
async logout() { await request('/logout', 'POST'); csrf = ''; },
|
||||
instances: () => request<Instance[]>('/instances'),
|
||||
images: () => request<Image[]>('/images'),
|
||||
builds: () => request<Build[]>('/builds'),
|
||||
usage: (instance?: string) => request<Usage>('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')),
|
||||
tree: (path: string, instance?: string) => request<Entry[]>('/ws/tree?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : '')),
|
||||
download: (path: string, instance?: string) => '/api/ws/download?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''),
|
||||
mkdir: (path: string, instance?: string) => request('/ws/mkdir' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path}),
|
||||
rename: (path: string, newPath: string, instance?: string) => request('/ws/rename' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path, new_path: newPath}),
|
||||
delete: (path: string, instance?: string) => request('/ws/delete?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''), 'DELETE'),
|
||||
async upload(path: string, file: File, instance?: string) { const form = new FormData(); form.set('path', path); form.set('file', file); return request('/ws/upload' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', form); },
|
||||
};
|
||||
}
|
||||
export const api = createApi();
|
||||
export const instancePath = (id: string) => '/instances/' + encodeURIComponent(id);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { createApi } from './api';
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {status});
|
||||
it('refreshes once after access expiry, retaining CSRF and retrying the request', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'csrf'}))
|
||||
.mockResolvedValueOnce(json({error:'expired'},401))
|
||||
.mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'csrf'}))
|
||||
.mockResolvedValueOnce(json([]));
|
||||
const api=createApi(fetcher);await api.login('u','password');expect(await api.instances()).toEqual([]);
|
||||
expect(fetcher.mock.calls.map(c=>c[0])).toEqual(['/api/login','/api/instances','/api/refresh','/api/instances']);
|
||||
expect(new Headers(fetcher.mock.calls[2][1]?.headers).get('X-CSRF-Token')).toBe('csrf');
|
||||
});
|
||||
it('can refresh an expired access cookie after a page reload without exposing tokens', async () => {
|
||||
vi.stubGlobal('document',{cookie:'other=x; dsh_csrf=restored'});
|
||||
try {
|
||||
const fetcher=vi.fn<typeof fetch>().mockResolvedValueOnce(json({},401)).mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'restored'})).mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'restored'}));
|
||||
await createApi(fetcher).me();
|
||||
expect(fetcher.mock.calls[1][0]).toBe('/api/refresh');
|
||||
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-CSRF-Token')).toBe('restored');
|
||||
} finally {vi.unstubAllGlobals();}
|
||||
});
|
||||
it('does not loop when refreshed access is rejected', async () => {
|
||||
const fetcher=vi.fn<typeof fetch>().mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'csrf'})).mockResolvedValueOnce(json({},401)).mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'csrf'})).mockResolvedValueOnce(json({},401));
|
||||
const api=createApi(fetcher);await api.login('u','password');await expect(api.me()).rejects.toMatchObject({status:401});expect(fetcher).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it('shares one refresh across concurrent expired requests', async () => {
|
||||
let refreshed=false;
|
||||
let complete!: (r:Response)=>void;
|
||||
const pending=new Promise<Response>(resolve=>{complete=resolve;});
|
||||
const fetcher=vi.fn<typeof fetch>().mockImplementation(async path=>{
|
||||
if(path==='/api/login') return json({user:{id:'u'},csrf_token:'csrf'});
|
||||
if(path==='/api/refresh') return pending;
|
||||
return refreshed?json([]):json({},401);
|
||||
});
|
||||
const api=createApi(fetcher);await api.login('u','password');
|
||||
const first=api.instances(),second=api.images();
|
||||
await vi.waitFor(()=>expect(fetcher.mock.calls.filter(c=>c[0]==='/api/refresh')).toHaveLength(1));
|
||||
refreshed=true;complete(json({user:{id:'u'},csrf_token:'csrf'}));
|
||||
expect(await Promise.all([first,second])).toEqual([[],[]]);
|
||||
expect(fetcher.mock.calls.filter(c=>c[0]==='/api/refresh')).toHaveLength(1);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { RuntimeRenew } from "./RuntimeRenew";
|
||||
import "./style.css";
|
||||
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { passwordResetError } from './passwordReset';
|
||||
describe('password reset validation', () => {
|
||||
it('enforces byte boundaries', () => {
|
||||
for (const count of [12, 72]) expect(passwordResetError('a'.repeat(count), 'a'.repeat(count))).toBe('');
|
||||
for (const count of [0, 11, 73]) expect(passwordResetError('a'.repeat(count), 'a'.repeat(count))).not.toBe('');
|
||||
expect(passwordResetError('中'.repeat(24), '中'.repeat(24))).toBe('');
|
||||
expect(passwordResetError('中'.repeat(25), '中'.repeat(25))).not.toBe('');
|
||||
});
|
||||
it('requires exact confirmation without trimming', () => {
|
||||
expect(passwordResetError('😀'.repeat(18), '😀'.repeat(18))).toBe('');
|
||||
expect(passwordResetError('😀'.repeat(19), '😀'.repeat(19))).not.toBe('');
|
||||
expect(passwordResetError('valid-password', 'other-password')).toContain('不一致');
|
||||
expect(passwordResetError(' valid-password ', ' valid-password ')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function passwordResetError(password: string, confirmation: string): string {
|
||||
const length = new TextEncoder().encode(password).length;
|
||||
if (length < 12 || length > 72) return '密码必须为 12–72 字节。';
|
||||
if (password !== confirmation) return '两次输入的密码不一致。';
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GiB, quotaBytes } from './quota';
|
||||
describe('quota validation', () => {
|
||||
it('converts presets and fractional GiB to integer bytes', () => {
|
||||
for (const n of [5,10,20,50]) expect(quotaBytes(String(n))).toBe(n * GiB);
|
||||
expect(quotaBytes('0.1')).toBe(Math.round(GiB / 10));
|
||||
expect(quotaBytes('10')).toBe(quotaBytes('10.0'));
|
||||
});
|
||||
it('rejects empty, nonfinite, negative and unsafe values', () => {
|
||||
for (const value of ['', ' ', 'invalid', '0', '-5', '0.09', 'Infinity', 'NaN', '1e30']) expect(quotaBytes(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export const GiB = 1073741824;
|
||||
export function quotaBytes(value: string): number | null {
|
||||
if (!value.trim()) return null;
|
||||
const gib = Number(value), bytes = Math.round(gib * GiB);
|
||||
return Number.isFinite(gib) && gib >= 0.1 && Number.isSafeInteger(bytes) ? bytes : null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { runtimeSelectionKey, selectRuntime } from './runtimeSelection';
|
||||
|
||||
const instances = [{ id: 'stopped', status: 'exited' }, { id: 'running', status: 'running' }];
|
||||
it('prefers running when no selection is stored', () => {
|
||||
expect(selectRuntime(instances, null)).toBe('running');
|
||||
});
|
||||
it('retains an explicit stopped selection', () => {
|
||||
expect(selectRuntime(instances, 'stopped')).toBe('stopped');
|
||||
});
|
||||
it('replaces deleted or inaccessible selection', () => {
|
||||
expect(selectRuntime(instances, 'deleted')).toBe('running');
|
||||
});
|
||||
it('selects an actionable stopped instance if none is running', () => {
|
||||
expect(selectRuntime(instances.slice(0, 1), null)).toBe('stopped');
|
||||
});
|
||||
it('clears selection for empty list', () => {
|
||||
expect(selectRuntime([], 'deleted')).toBeNull();
|
||||
});
|
||||
it('retains selection across reordering and status changes', () => {
|
||||
expect(selectRuntime([...instances].reverse(), 'stopped')).toBe('stopped');
|
||||
expect(selectRuntime([{ id: 'running', status: 'stopped' }], 'running')).toBe('running');
|
||||
});
|
||||
it('scopes storage keys per user', () => {
|
||||
expect(runtimeSelectionKey('one')).not.toBe(runtimeSelectionKey('two'));
|
||||
expect(runtimeSelectionKey('user/a')).toBe('dsh:selected-instance:user%2Fa');
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Instance } from './api';
|
||||
|
||||
export const runtimeSelectionKey = (userId: string) => 'dsh:selected-instance:' + encodeURIComponent(userId);
|
||||
|
||||
export function selectRuntime(instances: readonly Pick<Instance, 'id' | 'status'>[], selectedId: string | null): string | null {
|
||||
if (selectedId && instances.some(instance => instance.id === selectedId)) return selectedId;
|
||||
return instances.find(instance => instance.status === 'running')?.id ?? instances[0]?.id ?? null;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
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) {
|
||||
const loader = useRef(load); loader.current = load;
|
||||
const [data, setData] = useState<T>();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const generation = useRef(0);
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
setLoading(true); setError('');
|
||||
try { const value = await loader.current(); if (current === generation.current) setData(value); }
|
||||
catch (e) { if (current === generation.current) setError(message(e)); }
|
||||
finally { if (current === generation.current) setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { setData(undefined); void refresh(); const timer = interval ? window.setInterval(refresh, interval) : undefined; return () => { generation.current++; window.clearInterval(timer); }; }, [refresh, 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>}</>; }
|
||||
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;
|
||||
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>}</>};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { displayFilePath, fileScopeKey, joinFilePath, navigateDirectory } from './workspaceLocation';
|
||||
|
||||
it('keeps API paths relative at the root and in nested directories', () => {
|
||||
expect(joinFilePath('.', 'notes')).toBe('notes');
|
||||
expect(joinFilePath('projects/demo', 'notes')).toBe('projects/demo/notes');
|
||||
});
|
||||
|
||||
it('preserves hidden and sensitive entries without filtering', () => {
|
||||
expect(joinFilePath('.', '.env')).toBe('.env');
|
||||
expect(joinFilePath('.ssh', 'id_ed25519')).toBe('.ssh/id_ed25519');
|
||||
expect(joinFilePath('项目 空间', '配置.json')).toBe('项目 空间/配置.json');
|
||||
});
|
||||
|
||||
it('shows full template 7 instance paths without dot segments', () => {
|
||||
expect(displayFilePath('instance', '.')).toBe('/workspace');
|
||||
expect(displayFilePath('instance', '.config/dsh')).toBe('/workspace/.config/dsh');
|
||||
});
|
||||
|
||||
it('labels shared paths without promising a container mount', () => {
|
||||
expect(displayFilePath('shared', '.')).toBe('历史共享目录/');
|
||||
expect(displayFilePath('shared', 'old/project')).toBe('历史共享目录/old/project');
|
||||
});
|
||||
|
||||
it('changes the component scope on instance or mode changes to discard drafts and feedback', () => {
|
||||
expect(fileScopeKey('instance', 'one')).not.toBe(fileScopeKey('instance', 'two'));
|
||||
expect(fileScopeKey('instance', 'one')).not.toBe(fileScopeKey('shared', 'one'));
|
||||
expect(fileScopeKey('shared', 'one')).not.toBe(fileScopeKey('shared', 'two'));
|
||||
expect(fileScopeKey('shared', null)).not.toBe(fileScopeKey('instance', 'shared'));
|
||||
expect(fileScopeKey('instance', 'one')).toBe(fileScopeKey('instance', 'one'));
|
||||
});
|
||||
|
||||
it('locks root, breadcrumb and child navigation while a mutation is pending', () => {
|
||||
for (const target of ['.', 'parent', 'parent/current/child']) {
|
||||
expect(navigateDirectory('parent/current', target, true)).toBe('parent/current');
|
||||
expect(navigateDirectory('parent/current', target, false)).toBe(target);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type FileMode = 'instance' | 'shared';
|
||||
|
||||
export const joinFilePath = (path: string, name: string) => path === '.' ? name : path + '/' + name;
|
||||
|
||||
export function displayFilePath(mode: FileMode, path: string) {
|
||||
const root = mode === 'instance' ? '/workspace' : '历史共享目录';
|
||||
return root + (path === '.' ? (mode === 'shared' ? '/' : '') : '/' + path);
|
||||
}
|
||||
|
||||
export const fileScopeKey = (mode: FileMode, instanceId: string | null) => JSON.stringify([mode, instanceId]);
|
||||
|
||||
export const navigateDirectory = (current: string, target: string, busy: boolean) => busy ? current : target;
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","skipLibCheck":true,"moduleResolution":"Bundler","allowImportingTsExtensions":true,"resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx","strict":true},"include":["src","vite.config.ts"]}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({ plugins: [react()], server: { host: "127.0.0.1", proxy: { "/api": "http://127.0.0.1:8088", "/dsh": { target: "http://127.0.0.1:8088", ws: true } } } });
|
||||
Reference in New Issue
Block a user