From cd2923556ffad7aa7c5a0490d4bb28b3f37c78a4 Mon Sep 17 00:00:00 2001 From: dualchimerra Date: Thu, 24 Sep 2026 16:32:18 +0200 Subject: [PATCH 01/20] design: 2026 design system and app shell from the approved mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - styles/ds.css: the mockup stylesheet ported 1:1, every class prefixed with ds- so it lives next to the legacy primitives without collisions. Element resets are dropped (Tailwind preflight covers them). - tokens.css: semantic tokens take the mockup values (warm neutrals, lime accent for fills, deep green --accent-text for text, radii 7/10/16/20, light warm shadows, -1% tracking). Legacy primitives (.btn*, .card, .badge, .seg, .tab, .input, .kbd, .caption) follow the same look; ghost buttons get a hairline frame. - text-accent utilities and direct `color: var(--accent)` uses read --accent-text, so the light lime never ends up as text colour. - Shell as in the mockup: one white panel on the canvas, 236px sidebar with the collapse icon next to the name, project card, ВЕРСИЯ pill, step rail with round nodes and real counts, topbar with GPU · VRAM · CPU · RAM, task chip and runtime chip. All sidebar behaviour is kept (cursor advance/skip, version switch/new/export/delete, read-only outside the project, phone drawer, collapse). Co-Authored-By: Claude Opus 5.5 --- studio/web/src/App.tsx | 28 +- studio/web/src/components/BulkActionBar.tsx | 2 +- .../web/src/components/LLMTaggerWorkspace.tsx | 2 +- studio/web/src/components/Sidebar.test.tsx | 14 +- studio/web/src/components/Sidebar.tsx | 558 ++++++++--------- studio/web/src/components/SystemStats.tsx | 65 +- studio/web/src/components/Topbar.tsx | 152 ++--- studio/web/src/index.css | 3 +- studio/web/src/lib/labels.ts | 10 + studio/web/src/pages/QueueDetail.tsx | 2 +- .../pages/project/steps/Regularization.tsx | 4 +- .../pages/tools/generate/NumberListInput.tsx | 2 +- studio/web/src/styles/ds.css | 581 ++++++++++++++++++ studio/web/src/styles/info-button.css | 2 +- studio/web/src/styles/preprocess-crop.css | 2 +- studio/web/src/styles/tokens.css | 172 +++--- studio/web/src/styles/variables.css | 2 +- studio/web/src/styles/version-section.css | 26 +- studio/web/tailwind.config.js | 12 + 19 files changed, 1081 insertions(+), 558 deletions(-) create mode 100644 studio/web/src/lib/labels.ts create mode 100644 studio/web/src/styles/ds.css diff --git a/studio/web/src/App.tsx b/studio/web/src/App.tsx index 919c3d5..31d75f2 100644 --- a/studio/web/src/App.tsx +++ b/studio/web/src/App.tsx @@ -108,20 +108,22 @@ function RootLayout() { }, [navOpen]) return ( -
- setNavOpen(false)} /> - {isMobile && navOpen && ( -
setNavOpen(false)} - aria-hidden="true" - /> - )} - {/* Tailark 式 inset 面板:内容区作为一张浮在侧栏底色上的卡片(手机端全屏无边距)。 */} -
-
+ // The whole app is one white panel on the warm canvas (mockup .viewport / + // .app): sidebar and main column share it. On a phone the panel is + // full-bleed and the sidebar slides in as a drawer. +
+
+ setNavOpen(false)} /> + {isMobile && navOpen && ( +
setNavOpen(false)} + aria-hidden="true" + /> + )} +
setNavOpen(true)} /> -
+
diff --git a/studio/web/src/components/BulkActionBar.tsx b/studio/web/src/components/BulkActionBar.tsx index 62346ca..5c76fa5 100644 --- a/studio/web/src/components/BulkActionBar.tsx +++ b/studio/web/src/components/BulkActionBar.tsx @@ -379,7 +379,7 @@ function PositionToggle({ // 不抢「添加」主按钮的颜色。 const activeStyle = { background: 'var(--bg-canvas)', - color: 'var(--accent)', + color: 'var(--accent-text, var(--accent))', boxShadow: 'inset 0 0 0 1px color-mix(in oklch, var(--accent) 30%, var(--border-default))', } diff --git a/studio/web/src/components/LLMTaggerWorkspace.tsx b/studio/web/src/components/LLMTaggerWorkspace.tsx index a8a43f3..3d6ac32 100644 --- a/studio/web/src/components/LLMTaggerWorkspace.tsx +++ b/studio/web/src/components/LLMTaggerWorkspace.tsx @@ -701,7 +701,7 @@ function Step({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/studio/web/src/components/Sidebar.test.tsx b/studio/web/src/components/Sidebar.test.tsx index 13eba53..0885a98 100644 --- a/studio/web/src/components/Sidebar.test.tsx +++ b/studio/web/src/components/Sidebar.test.tsx @@ -113,13 +113,11 @@ describe('Sidebar (PP0)', () => { it('marks the active route', () => { renderAt('/tools/presets') const link = screen.getByRole('link', { name: /预设/ }) - // 活跃 link:bg-surface + font-semibold(重设计 token 化后的活跃态) - expect(link.className).toMatch(/bg-surface/) - expect(link.className).toMatch(/font-semibold/) - // 非活跃 link 没有这俩 + // 活跃 link 带 ds-is-active(2026 设计:白底卡片 + 细描边) + expect(link.className).toMatch(/ds-is-active/) + // 非活跃 link 没有 const queue = screen.getByRole('link', { name: /队列/ }) - expect(queue.className).not.toMatch(/bg-surface/) - expect(queue.className).not.toMatch(/font-semibold/) + expect(queue.className).not.toMatch(/ds-is-active/) }) it('does not include the removed Datasets link', () => { @@ -142,9 +140,9 @@ describe('Sidebar (PP0)', () => { renderAt('/queue', STICKY) // 在队列页:队列高亮,概览不高亮(inRoute 门控,避免 currentStep===null 误判) const queue = screen.getByRole('link', { name: /队列/ }) - expect(queue.className).toMatch(/bg-surface/) + expect(queue.className).toMatch(/ds-is-active/) const overview = screen.getByRole('link', { name: /概览/ }) - expect(overview.className).not.toMatch(/bg-surface/) + expect(overview.className).not.toMatch(/ds-is-active/) }) it('shows no project section without a sticky selection', () => { diff --git a/studio/web/src/components/Sidebar.tsx b/studio/web/src/components/Sidebar.tsx index 618f1b8..c7da735 100644 --- a/studio/web/src/components/Sidebar.tsx +++ b/studio/web/src/components/Sidebar.tsx @@ -1,7 +1,10 @@ -import { Fragment, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { api, PHASE_ORDER, PHASE_SKIPPABLE, type Version, type VersionPhase, type VersionStatus } from '../api/client' +import { api, PHASE_ORDER, PHASE_SKIPPABLE, type Task, type Version, type VersionPhase, type VersionStatus } from '../api/client' +import { splitOptional } from '../lib/labels' +import { useEventStream, type StudioEvent } from '../lib/useEventStream' +import { useMonitorProgress } from '../lib/useMonitorProgress' import { useSettingsDrawer } from '../lib/SettingsDrawer' import { useToast } from './Toast' @@ -48,114 +51,80 @@ function useProjectView(): ProjectView | null { return null } -// ── icons ────────────────────────────────────────────────────────────────── +// ── icons (mockup sizes: 15px nav icons, 1.7 stroke) ──────────────────────── +const svg = (d: React.ReactNode, size = 15, w = 1.7) => ( + {d} +) const I = { - folder: , - queue: , - preset: , - monitor: , - cog: , - image: , - soup: , - check: , - chevL: , - chevR: , - download:, - upscale: , - filter: , - tag: , - edit: , - reg: , - train: , - overview:, - plus: , - sun: , - moon: , - branch: , - swap: , - trash: , + folder: svg(), + folderTile: svg(, 15, 1.9), + queue: svg(<>), + preset: svg(<>), + monitor: svg(), + cog: svg(<>), + image: svg(<>), + soup: svg(<>), + overview: svg(<>), + download: svg(), + branch: svg(<>, 11, 2), + chevD: svg(, 11, 2.2), + check: svg(, 9, 3.4), + checkSm: svg(, 12, 2.4), + collapse: svg(<>, 15, 1.8), + expand: svg(<>, 15, 1.8), + plus: svg(, 13, 2), + export: svg(, 13, 1.8), + trash: svg(<>, 13, 1.8), + updown: , + brand: , } // ── version status dot (ADR-0007 §11.3-B) ───────────────────────────────── const STATUS_DOT: Record = { - preparing: 'dot dot-warn', - training: 'dot dot-running', - completed: 'dot dot-ok', - failed: 'dot dot-err', - canceled: 'dot dot-neutral', + preparing: 'ds-dot ds-dot-warn', + training: 'ds-dot ds-dot-run', + completed: 'ds-dot ds-dot-ok', + failed: 'ds-dot ds-dot-err', + canceled: 'ds-dot ds-dot-mute', } // ── nav item ─────────────────────────────────────────────────────────────── -function navItemClass(active: boolean, collapsed: boolean, prominent: boolean): string { - return [ - 'flex w-full items-center gap-2.5 rounded-md no-underline transition-colors relative border-none cursor-pointer text-sm', - collapsed ? 'h-8 px-0 justify-center' : prominent ? 'h-8 px-2.5 justify-start' : 'h-8 px-2.5 justify-start', - active - ? 'bg-surface text-fg-primary font-semibold shadow-sm ring-1 ring-[rgb(9_9_11/0.06)] [&>svg]:text-fg-primary' - : 'bg-transparent text-fg-secondary font-medium hover:bg-overlay hover:text-fg-primary [&>svg]:text-fg-tertiary', - ].join(' ') +function navCls(active: boolean, collapsed: boolean): string { + return ['ds-nav-item', active ? 'ds-is-active' : '', collapsed ? 'justify-center' : ''].filter(Boolean).join(' ') } -function NavItem({ to, label, icon, active, collapsed, prominent = false }: { +function NavItem({ to, label, icon, active, collapsed, tail }: { to: string; label: string; icon: React.ReactNode; active: boolean; collapsed: boolean - /** 顶级 tab 用更大字号 + 更大 padding,跟项目下属 sub-nav 区分。 */ - prominent?: boolean + /** Right-aligned count, as in the mockup (Датасет 532, Очередь 2). */ + tail?: React.ReactNode }) { return ( - + {icon} - {!collapsed && {label}} + {!collapsed && {label}} + {!collapsed && tail != null && {tail}} ) } /** NavItem 的 button 变体 —— 给设置抽屉用,不走路由。 */ -function NavButton({ onClick, label, icon, active, collapsed, prominent = false }: { +function NavButton({ onClick, label, icon, active, collapsed }: { onClick: () => void; label: string; icon: React.ReactNode; active: boolean; collapsed: boolean - prominent?: boolean }) { return ( - ) } -// ── project info block (项目名 + active version label,放最上) ────────────── -function ProjectInfoBlock({ collapsed }: { collapsed: boolean }) { - const view = useProjectView() - if (!view) return null - if (collapsed) return null - const { project } = view - return ( -
-
- {project.title} -
-
- slug / {project.slug} -
-
- ) -} - -// ── version picker block(⑂ 版本名 + action:切换 popover / 新建 / 导出 / 删除)─ -// 层级锚点:这里是 project scope → version scope 的边界,正名为「版本选择器」 -// (不再叫"训练版本"——训练是第 6 步 STEP)。离开项目页(只读粘性快照)时退化为 -// 只读身份行、action 全部收起;要管理版本回到项目内。 -// 常驻:⑂ 版本名 [⇄切换](切换仅多版本时出现) +// ── version bar(ВЕРСИЯ + pill;hover 才浮现 新建 / 导出 / 删除)──────────── +// 层级锚点:这里是 project scope → version scope 的边界。离开项目页(只读粘性 +// 快照)时退化为只读 pill、action 全部收起;要管理版本回到项目内。 +// pill:多版本时点击打开切换 popover(title=切换版本) // hover / 键盘 focus 才浮现:[+新建] [⬇导出] [🗑删除](删除仅多版本时出现) -function VersionPickerBlock({ collapsed }: { collapsed: boolean }) { +function VersionBar({ collapsed }: { collapsed: boolean }) { const { t } = useTranslation() const view = useProjectView() const [switching, setSwitching] = useState(false) @@ -164,70 +133,50 @@ function VersionPickerBlock({ collapsed }: { collapsed: boolean }) { if (collapsed) return null // 折叠态不显示(版本没有独立页面) const { project, activeVersion } = view const multiVersion = project.versions.length > 1 - - const idIcon = {I.branch} - const label = ( - - {activeVersion?.label ?? '—'} - + const canSwitch = view.interactive && multiVersion + + const pillInner = ( + <> + {I.branch} + {activeVersion?.label ?? '—'} + {activeVersion && } + {canSwitch && I.chevD} + ) - // 只读(离开项目):身份 + 状态点,无 action。 - if (!view.interactive) { - return ( -
- {idIcon} - {label} - {activeVersion && } -
- ) - } - - const { onSelectVersion, onCreateVersion, onExportTrain, onDeleteVersion, exporting } = view - return ( -
- {idIcon} - {label} - -
- {/* 新建 / 导出 / 删除:hover(或键盘 focus)才浮现。用 hidden→flex(而非 - opacity)让它们平时不占位,版本名 flex-1 得以伸展;hover 时才挤占空间。 */} - - onCreateVersion()} /> - +
+ {t('sidebar.versionsLabel')} + {canSwitch ? ( + + ) : ( + {pillInner} + )} + + {view.interactive && ( + // 新建 / 导出 / 删除:hover(或键盘 focus)才浮现,平时不占位。 + + view.onCreateVersion()} /> + {multiVersion && ( { if (activeVersion) onDeleteVersion(activeVersion.id) }} + onClick={() => { if (activeVersion) view.onDeleteVersion(activeVersion.id) }} /> )} - {/* 切换:常驻(多版本才有意义)。 */} - {multiVersion && ( - setSwitching((v) => !v)} - /> - )} -
+ )} - {switching && ( + {switching && view.interactive && ( { onSelectVersion(vid); setSwitching(false) }} + onPick={(vid) => { view.onSelectVersion(vid); setSwitching(false) }} onClose={() => setSwitching(false)} /> )} @@ -236,9 +185,9 @@ function VersionPickerBlock({ collapsed }: { collapsed: boolean }) { } /** 版本行的 icon-only action 按钮。 */ -function VerAction({ icon, title, onClick, disabled = false, active = false, danger = false }: { +function VerAction({ icon, title, onClick, disabled = false, danger = false }: { icon: React.ReactNode; title: string; onClick: () => void - disabled?: boolean; active?: boolean; danger?: boolean + disabled?: boolean; danger?: boolean }) { return ( ) })} @@ -309,8 +259,27 @@ function VersionSwitchPopover({ versions, activeId, anchorRef, onPick, onClose } ) } +// ── queue data shared by the sidebar (pending count + running task) ───────── +function useQueueSnapshot(): { pending: number; running: Task | null } { + const [pending, setPending] = useState(0) + const [running, setRunning] = useState(null) + const refresh = useCallback(async () => { + try { + const [run, pend] = await Promise.all([api.listQueue('running'), api.listQueue('pending')]) + setRunning(run.length > 0 ? run[0] : null) + setPending(pend.length) + } catch { /* offline / tests: keep the last known values */ } + }, []) + useEffect(() => { void refresh() }, [refresh]) + useEventStream( + (evt: StudioEvent) => { if (evt.type === 'task_state_changed') void refresh() }, + { onOpen: () => void refresh() }, + ) + return { pending, running } +} + // ── project stepper nav ──────────────────────────────────────────────────── -function ProjectStepperNav({ pid, activeVid, currentStep, version, collapsed, inRoute }: { +function ProjectStepperNav({ pid, activeVid, currentStep, version, collapsed, inRoute, trainPct }: { pid: string activeVid: string | null currentStep: string | null @@ -318,27 +287,28 @@ function ProjectStepperNav({ pid, activeVid, currentStep, version, collapsed, in collapsed: boolean /** 是否当前正处于该项目的路由内(决定"概览"是否高亮;离开项目页只导航不高亮)。 */ inRoute: boolean + /** Training progress of the active version, when its task is running. */ + trainPct: number | null }) { const { t } = useTranslation() const navigate = useNavigate() const { toast } = useToast() const ctx = useProjectCtx() + const view = useProjectView() + const project = view?.project ?? null + const stats = version?.stats - // 项目级 ① 跟"概览"同款:圆盘里放 icon、无序号、无完成绿色态。 - // version 级 phase 重编号 1-6(每个 version 自己一段流水线,ADR 0010 加 - // preprocess phase 后是 6 个 version 级 step)。 + // version 级 phase 编号 1-5(每个 version 自己一段流水线)。 const STEPS = [ - { key: 'download', labelKey: 'nav.download', idx: '', icon: I.download, scope: 'project' as const }, - { key: 'curate', labelKey: 'nav.curate', idx: '1', icon: I.filter, scope: 'version' as const }, - { key: 'preprocess', labelKey: 'nav.preprocess', idx: '2', icon: I.upscale, scope: 'version' as const }, - { key: 'edit', labelKey: 'nav.tagEdit', idx: '3', icon: I.edit, scope: 'version' as const }, - { key: 'reg', labelKey: 'nav.reg', idx: '4', icon: I.reg, scope: 'version' as const }, - { key: 'train', labelKey: 'nav.train', idx: '5', icon: I.train, scope: 'version' as const }, + { key: 'curate', labelKey: 'nav.curate', idx: '1', tail: stats?.train_image_count }, + { key: 'preprocess', labelKey: 'nav.preprocess', idx: '2', tail: project?.preprocess_image_count }, + { key: 'edit', labelKey: 'nav.tagEdit', idx: '3', tail: stats?.tagged_image_count }, + { key: 'reg', labelKey: 'nav.reg', idx: '4', tail: stats?.reg_image_count }, + { key: 'train', labelKey: 'nav.train', idx: '5', tail: trainPct != null ? `${trainPct}%` : undefined }, ] const overviewActive = inRoute && currentStep === null - // ADR-0007 §11.5 cursor 派生:cursor 之前的 phase = done(仅 version 级)。 - // 项目级 ①② 不参与 cursor、不显示完成态。 + // ADR-0007 §11.5 cursor 派生:cursor 之前的 phase = done。 const cursorPhase: VersionPhase = (version?.phase as VersionPhase | undefined) ?? 'curating' const cursorIdx = PHASE_ORDER.indexOf(cursorPhase) @@ -375,135 +345,100 @@ function ProjectStepperNav({ pid, activeVid, currentStep, version, collapsed, in } } - const linkCls = (active: boolean, indent = false) => [ - 'flex items-center gap-2.5 rounded-md h-8 text-sm no-underline transition-colors border-none', - collapsed ? 'px-0 justify-center' : 'px-2.5 justify-start', - !collapsed && indent ? 'ml-3' : '', - active ? 'bg-surface text-fg-primary font-semibold shadow-sm ring-1 ring-[rgb(9_9_11/0.06)]' : 'bg-transparent text-fg-secondary font-normal hover:bg-overlay hover:text-fg-primary', - ].join(' ') - return ( -
- {/* 项目名 + 当前 version label,放最顶 */} - - - - - {I.overview} - - {!collapsed && {t('nav.overview')}} - - - {STEPS.map((s, i) => { - const label = t(s.labelKey) - const isActive = s.key === currentStep - const isProject = s.scope === 'project' - const phase = STEP_KEY_TO_PHASE[s.key] - const phaseIdx = phase != null ? PHASE_ORDER.indexOf(phase) : -1 - // ADR-0007 §11.5-A: sidebar 是 cursor 推进主通道。 - // - phase_idx < cursorIdx → done (绿数字,Link) - // - phase_idx == cursorIdx → 当前 cursor (Link) - // - phase_idx == cursorIdx+1 → "下一步",呼吸 button,点击触发 advance/skip - // - phase_idx > cursorIdx+1 → disabled - const isCursorCurrent = !isProject && phaseIdx === cursorIdx - const isNextStep = !isProject && phaseIdx === cursorIdx + 1 - const isFuturePhase = !isProject && phaseIdx > cursorIdx + 1 - const isDone = isProject ? false : isStepDone(s.key) - - const href = (isFuturePhase || isNextStep) - ? null - : isProject - ? `/projects/${pid}/${s.key}` - : activeVid ? `/projects/${pid}/v/${activeVid}/${s.key}` : null - - // 视觉强度:cursor 当前(实心 accent)> cursor+1(弱底 + 静态 ring)> 已完成(绿)> 默认 - // 注:之前 cursor+1 用 animate-pulse 反而盖过 cursor 当前,去掉动画;改用 ring 静态提示。 - const badgeCls = isDone - ? 'bg-ok-soft text-ok' - : isCursorCurrent - ? 'bg-accent text-accent-fg' - : isNextStep - ? 'bg-surface text-accent ring-1 ring-inset ring-accent' - : isActive - ? 'bg-accent-soft text-accent' - : 'bg-overlay text-fg-tertiary' - - // 项目级 ①② badge 内放 icon(跟"概览" ≡ 同款),不要数字 / 不要绿色态。 - // version 级 badge 始终放数字;完成时数字变绿;cursor+1 时背景 accent + 呼吸。 - const badgeContent = isProject ? s.icon : s.idx - - const inner = ( - <> - svg]:w-3 [&>svg]:h-3 ${badgeCls}`}> - {badgeContent} - - {!collapsed && {label}} - {!collapsed && isActive && } - - ) + <> + {!collapsed && project && ( + + {I.folderTile} + + {project.title} + {project.slug} + + {I.updown} + + )} - // version 级 step(筛选→训练)整体再缩进一层,表达从属于上方的 version 选择器。 - const indent = s.scope === 'version' - - let stepNode: React.ReactNode - if (isNextStep) { - // 推进入口:button + onClick - stepNode = ( - + + + + + +
+ {STEPS.map((s) => { + const full = t(s.labelKey) + const { short, optional } = splitOptional(full) + const isActive = s.key === currentStep + const phase = STEP_KEY_TO_PHASE[s.key] + const phaseIdx = phase != null ? PHASE_ORDER.indexOf(phase) : -1 + // ADR-0007 §11.5-A: sidebar 是 cursor 推进主通道。 + // - phase_idx < cursorIdx → done + // - phase_idx == cursorIdx → 当前 cursor + // - phase_idx == cursorIdx+1 → "下一步",button,点击触发 advance/skip + // - phase_idx > cursorIdx+1 → disabled + const isCursorCurrent = phaseIdx === cursorIdx + const isNextStep = phaseIdx === cursorIdx + 1 + const isFuturePhase = phaseIdx > cursorIdx + 1 + const isDone = isStepDone(s.key) + + const href = (isFuturePhase || isNextStep || !activeVid) + ? null + : `/projects/${pid}/v/${activeVid}/${s.key}` + + const cls = [ + 'ds-step', + isDone ? 'ds-is-done' : '', + isCursorCurrent ? 'ds-is-cur' : '', + isActive ? 'ds-is-active' : '', + optional ? 'ds-is-opt' : '', + collapsed ? 'justify-center' : '', + ].filter(Boolean).join(' ') + + const inner = ( + <> + {isDone ? I.check : isCursorCurrent ? s.idx : ''} + {!collapsed && {short}} + {!collapsed && s.tail != null && {s.tail}} + ) - } else if (!href) { - stepNode = ( - - {inner} - - ) - } else { - stepNode = ( - + const title = collapsed ? `${s.idx}. ${full}` : optional ? full : undefined + + if (isNextStep) { + return ( + + ) + } + if (!href) { + return ( + + {inner} + + ) + } + return ( + {inner} ) - } - - // 在 scope 从 project 切到 version 的边界("预处理"和"筛选"之间) - // 插入 VersionPickerBlock —— 版本选择紧靠 version 级 phase 上方。 - const prev = STEPS[i - 1] - const isBoundary = prev && prev.scope === 'project' && s.scope === 'version' - if (!isBoundary) return stepNode - return ( - - - {stepNode} - - ) - })} -
+ })} +
+ ) } // ── sidebar ──────────────────────────────────────────────────────────────── const SIDEBAR_KEY = 'studio.sidebar.expanded' -/** Props are phone-only. On desktop `mobile` is false and every branch below - * collapses to exactly what the component rendered before. */ +/** Props are phone-only. On desktop `mobile` is false. */ export default function Sidebar({ mobile = false, mobileOpen = false, @@ -517,6 +452,7 @@ export default function Sidebar({ const location = useLocation() const view = useProjectView() const settingsDrawer = useSettingsDrawer() + const queue = useQueueSnapshot() // 路由内的 pid(用于步骤高亮);离开项目页后回退到粘性快照的项目 id,让项目区 // 跨页保留用于导航。 @@ -534,6 +470,14 @@ export default function Sidebar({ // 有项目(路由内 live 或粘性快照)就展示项目区 const inProject = view !== null && pid !== null + // Training progress for the active version's step tail. + const runningForVersion = queue.running && activeVid && String(queue.running.version_id ?? '') === activeVid + ? queue.running : null + const { state: monitor } = useMonitorProgress(runningForVersion?.id ?? null) + const trainPct = runningForVersion && monitor?.step != null && monitor.total_steps + ? Math.round((monitor.step / monitor.total_steps) * 100) + : null + const [expandedOverride, setExpandedOverride] = useState(() => { try { const v = sessionStorage.getItem(SIDEBAR_KEY) @@ -555,12 +499,11 @@ export default function Sidebar({ return location.pathname.startsWith(path) } - // On a phone the rail is always full width (a 58px icon rail plus a drawer is - // the worst of both) and slides in over the content instead of squeezing it. + // On a phone the rail is always full width and slides in over the content. const asideClass = mobile - ? 'fixed inset-y-0 left-0 z-50 w-[min(86vw,var(--sidebar-w))] bg-sunken border-r border-subtle flex flex-col overflow-hidden shadow-xl transition-transform duration-200 ease-out' + ? 'ds-side fixed inset-y-0 left-0 z-50 w-[min(86vw,var(--sidebar-w))] bg-surface shadow-xl transition-transform duration-200 ease-out' + (mobileOpen ? ' translate-x-0' : ' -translate-x-full') - : 'shrink-0 bg-sunken flex flex-col overflow-hidden h-full transition-[width] duration-[160ms] ease-in-out' + : 'ds-side overflow-hidden h-full transition-[width] duration-[160ms] ease-in-out' return ( ) diff --git a/studio/web/src/components/SystemStats.tsx b/studio/web/src/components/SystemStats.tsx index 0ff8f2b..203e2b7 100644 --- a/studio/web/src/components/SystemStats.tsx +++ b/studio/web/src/components/SystemStats.tsx @@ -2,28 +2,17 @@ import { useEffect, useState } from 'react' import { api, type SystemStats as SystemStatsData } from '../api/client' import { useEventStream } from '../lib/useEventStream' -function barColor(pct: number): string { - if (pct > 90) return 'var(--err)' - if (pct > 70) return 'var(--warn)' - return 'var(--ok)' -} - -/** redesign 原型 SystemStats:一个 sunken pill,CPU/GPU/MEM 各为 - * caption 标签 + 36px 迷你进度条 + 百分比;VRAM 用文本 used/totalG。 - * (prototype shell.jsx → SystemStats) */ -function MeterItem({ label, pct, tooltip }: { label: string; pct: number; tooltip: string }) { +/** Topbar system load, as in the mockup: GPU · VRAM · CPU · RAM, each a label, + * a value and a 20px bar, separated by full-height hairlines. A bar turns + * amber above 90%. */ +function MeterItem({ label, value, pct, tooltip }: { label: string; value: string; pct: number; tooltip: string }) { const clamped = Math.min(100, Math.max(0, pct)) return ( -
- {label} -
-
-
- {Math.round(clamped)}% -
+ + {label} + {value} + 90 ? 'ds-hot' : undefined} style={{ width: `${clamped}%` }} /> + ) } @@ -68,35 +57,35 @@ export default function SystemStats() { const gpuLabel = gpu0 ? `${gpu0.name}${gpuTempText}${gpuExtra}` : '' return ( -
- +
{gpu0 && ( )} + {gpu0 && ( + + )} + - {gpu0 && ( -
- VRAM - - {gpu0.vram_used_gb.toFixed(1)}/{Math.round(gpu0.vram_total_gb)}G - -
- )}
) } diff --git a/studio/web/src/components/Topbar.tsx b/studio/web/src/components/Topbar.tsx index cc1ae09..08c0643 100644 --- a/studio/web/src/components/Topbar.tsx +++ b/studio/web/src/components/Topbar.tsx @@ -7,11 +7,12 @@ import { useEventStream, type StudioEvent } from '../lib/useEventStream' import { useMonitorProgress } from '../lib/useMonitorProgress' import { useRuntimeModeOptional } from '../lib/RuntimeMode' import { useSettingsDrawer } from '../lib/SettingsDrawer' +import { splitOptional } from '../lib/labels' import CommandPalette from './CommandPalette' import SystemStats from './SystemStats' const SearchIcon = ( - + ) @@ -29,14 +30,13 @@ function RuntimeModeBadge() { if (!info) return null return ( - {t(`runtimeMode.${mode}.name`)} + + {t(`runtimeMode.${mode}.name`)} ) } @@ -108,18 +108,20 @@ function useBreadcrumbs(): Crumb[] { const versionLabel = ctx?.activeVersion?.label ?? `v${parts[vIdx + 1]}` const vid = parts[vIdx + 1] crumbs.push({ label: versionLabel, mono: true }) + // Step crumbs read exactly like the sidebar steps (short form). const stepLabels: Record = { - curate: t('breadcrumb.curate'), - edit: t('breadcrumb.tagEdit'), - reg: t('breadcrumb.reg'), - train: t('breadcrumb.train'), + curate: splitOptional(t('nav.curate')).short, + preprocess: splitOptional(t('nav.preprocess')).short, + edit: splitOptional(t('nav.tagEdit')).short, + reg: splitOptional(t('nav.reg')).short, + train: splitOptional(t('nav.train')).short, } const step = parts[vIdx + 2] if (step && stepLabels[step]) { crumbs.push({ label: stepLabels[step], to: `/projects/${projectId}/v/${vid}/${step}` }) } } else if (parts[2] === 'download') { - crumbs.push({ label: t('breadcrumb.download'), to: `/projects/${projectId}/download` }) + crumbs.push({ label: t('nav.download'), to: `/projects/${projectId}/download` }) } return crumbs } @@ -225,103 +227,83 @@ export default function Topbar({ return ( <> -
+
{mobile && ( )} -
+
{(mobile ? crumbs.slice(-1) : crumbs).map((b, i, shown) => { const isLast = i === shown.length - 1 - const cls = - `text-sm ${b.mono ? 'font-mono' : ''} ` + - `truncate ${mobile ? 'max-w-full' : 'max-w-[280px]'} px-1.5 py-1 rounded-md ` + - (isLast - ? 'text-fg-primary font-medium' - : 'text-fg-tertiary hover:text-fg-primary hover:bg-overlay transition-colors') + const cls = [b.mono ? 'ds-mono' : '', 'truncate', mobile ? 'max-w-full' : 'max-w-[280px]'].filter(Boolean).join(' ') return ( - - {i > 0 && ( - - )} - {!isLast && b.to ? ( - {b.label} - ) : ( - {b.label} - )} + + {i > 0 && /} + {isLast + ? {b.label} + : b.to + ? {b.label} + : {b.label}} ) })}
- +
+ + + {runningTask && ( + + )} - {runningTask && mobile && ( - - )} + {!runningTask && pendingCount > 0 && ( + + )} - {runningTask && !mobile && ( - - )} + {/* 本 fork:公告栏铃铛随 in-app updater/announcements 移除 */} + + {!mobile && } - {!runningTask && pendingCount > 0 && ( - )} - - {/* 本 fork:公告栏铃铛随 in-app updater/announcements 移除 */} - - {!mobile && } - - +
{t('queueDetail.backToQueue')} - Task #{taskId} + Task #{taskId}

diff --git a/studio/web/src/pages/project/steps/Regularization.tsx b/studio/web/src/pages/project/steps/Regularization.tsx index 46ecdb5..9eb4295 100644 --- a/studio/web/src/pages/project/steps/Regularization.tsx +++ b/studio/web/src/pages/project/steps/Regularization.tsx @@ -790,7 +790,7 @@ function GrpCard({ {t('reg.excludeMetaPrefix')}{' '} - {excludedCount} + {excludedCount} {modeHint && · {modeHint}} } diff --git a/studio/web/src/pages/tools/generate/NumberListInput.tsx b/studio/web/src/pages/tools/generate/NumberListInput.tsx index 4d9f6ac..01a3bd8 100644 --- a/studio/web/src/pages/tools/generate/NumberListInput.tsx +++ b/studio/web/src/pages/tools/generate/NumberListInput.tsx @@ -91,7 +91,7 @@ export default function NumberListInput({ padding: '2px 4px 2px 8px', borderRadius: 999, background: 'var(--accent-soft)', - color: 'var(--accent)', + color: 'var(--accent-text, var(--accent))', border: '1px solid transparent', }} > diff --git a/studio/web/src/styles/ds.css b/studio/web/src/styles/ds.css new file mode 100644 index 0000000..9fdcea7 --- /dev/null +++ b/studio/web/src/styles/ds.css @@ -0,0 +1,581 @@ +/* =========================================================================== + AnimaTrainHub design system — ported 1:1 from the approved mockup + (Design artifact "Конфиг обучения — редизайн", design/shared.css). + + Every class carries a ds- prefix so it can live next to the legacy + primitives without collisions. Page components use these classes with the + same structure as the artboards. Generated by port_css.js — edit the source + mockup stylesheet semantics, not the prefixing. + =========================================================================== */ + +/* scoped replacements for the mockup's element resets */ +.ds-app { font-family: var(--font); font-size: 14px; line-height: 1.45; color: var(--ink); -webkit-font-smoothing: antialiased; } +.ds-tbl { border-collapse: collapse; width: 100%; } +.ds-pagehead h1, .ds-pagehead h2, .ds-pagehead h3 { margin: 0; } + +:root{ + --canvas:#ebebe9; --panel:#ffffff; --area:#fafaf9; --card:#ffffff; --sunken:#f5f5f4; --hover:#f2f2f0; + --ink:#16181a; --ink-2:#4e5552; --ink-3:#6b726e; --ink-4:#878e89; + --line:#ececea; --line-2:#e3e3e0; --line-3:#d4d5d1; + --green:#b4e868; --green-600:#a3db52; --green-ink:#1b2810; + --green-soft:#eef7e2; --green-line:#dceec4; --green-text:#4a7429; + --olive:#56684a; --olive-2:#3d4d34; + --red-soft:#fdeaea; --red-text:#b3402f; --red-line:#f7d9d9; + --amber-soft:#fdf3d9; --amber-text:#87680f; --amber-line:#f4e5b8; + --blue-soft:#e9f0fb; --blue-text:#39619c; --blue-line:#d9e5f6; + --r-card:16px; --r-ctl:10px; --r-tile:9px; + --sh-card:0 1px 2px rgba(20,22,20,.035); + --font:'Geist','Inter',system-ui,sans-serif; --mono:'Geist Mono',ui-monospace,Menlo,monospace; +} + + + + + + + +/* ---------- shell ---------- */ +.ds-viewport{padding:22px;display:flex;overflow:hidden} +.ds-app{flex:1;display:grid;grid-template-columns:236px 1fr;background:var(--panel);border:1px solid var(--line-2);border-radius:20px;box-shadow:0 1px 2px rgba(20,22,20,.04),0 12px 40px -18px rgba(20,22,20,.18);overflow:hidden} +.ds-side{display:flex;flex-direction:column;border-right:1px solid var(--line);min-height:0} +.ds-brand{display:flex;align-items:center;gap:9px;height:52px;padding:0 18px;flex:none} +.ds-brand-mark{width:25px;height:25px;border-radius:7px;background:var(--ink);display:grid;place-items:center;color:#fff;flex:none} +.ds-brand-name{font-size:13.5px;font-weight:600;letter-spacing:-.02em} +.ds-brand-name i{font-style:normal;color:var(--ink-3);font-weight:500} +.ds-sidenav{flex:1;min-height:0;display:flex;flex-direction:column;gap:2px;padding:0 12px 14px} +.ds-sidefoot{border-top:1px solid var(--line);display:flex;flex-direction:column;gap:2px;padding:10px 12px;flex:none} +.ds-nav-item{display:flex;align-items:center;gap:10px;width:100%;height:32px;padding:0 9px;border-radius:var(--r-ctl);font-size:13px;color:var(--ink-2);letter-spacing:-.01em} +.ds-nav-item svg{flex:none;color:var(--ink-3)} +.ds-nav-item.ds-is-active{background:var(--card);color:var(--ink);font-weight:600;box-shadow:0 0 0 1px rgba(20,22,20,.06),0 1px 2px rgba(20,22,20,.05)} +.ds-nav-item.ds-is-active svg{color:var(--ink)} +.ds-nav-item.ds-is-muted{color:var(--ink-4)} +.ds-nav-tail{margin-left:auto;font-family:var(--mono);font-size:10.5px;color:var(--ink-4)} +.ds-nav-sub{display:flex;flex-direction:column;gap:2px;margin-left:12px} +.ds-proj-block{padding:8px 9px 6px} +.ds-proj-title{font-size:13px;font-weight:600;letter-spacing:-.015em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-proj-slug{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);margin-top:1px} +.ds-ver-row{display:flex;align-items:center;gap:8px;height:32px;padding:0 9px;border-radius:var(--r-ctl);font-size:13px;color:var(--ink-2);background:var(--sunken)} +.ds-ver-chip{width:19px;height:19px;border-radius:5px;background:var(--card);box-shadow:0 0 0 1px rgba(20,22,20,.08);display:grid;place-items:center;color:var(--ink-3);flex:none} +.ds-ver-name{flex:1;min-width:0;font-family:var(--mono);font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-ver-act{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;color:var(--ink-3);flex:none} +.ds-step-badge{width:19px;height:19px;border-radius:5px;display:grid;place-items:center;font-size:10.5px;font-weight:600;font-family:var(--mono);flex:none;background:var(--sunken);color:var(--ink-3)} +.ds-step-badge.ds-done{background:var(--green-soft);color:var(--green-text)} +.ds-step-badge.ds-cur{background:var(--ink);color:#fff} +.ds-step-badge.ds-next{background:var(--card);color:var(--green-text);box-shadow:inset 0 0 0 1px var(--green-600)} +.ds-dot{width:6px;height:6px;border-radius:999px;flex:none} +.ds-dot-ok{background:#5f9138}.ds-dot-run{background:var(--green-600)}.ds-dot-warn{background:#c8961f}.ds-dot-err{background:#b3402f}.ds-dot-mute{background:var(--line-3)} + +/* ---------- topbar ---------- */ +.ds-main{background:var(--area);display:flex;flex-direction:column;min-width:0} +.ds-topbar{height:52px;flex:none;display:flex;align-items:center;gap:10px;padding:0 20px;border-bottom:1px solid var(--line);background:var(--panel)} +.ds-crumbs{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ink-3);min-width:0} +.ds-crumbs b{font-weight:500;color:var(--ink)} +.ds-crumbs .ds-mono{font-family:var(--mono);font-size:11.5px} +.ds-crumb-sep{color:var(--ink-4)} +.ds-tb-right{margin-left:auto;display:flex;align-items:center;gap:8px} +.ds-tb-chip{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);font-size:11.5px;font-weight:500;color:var(--ink-2);box-shadow:var(--sh-card);white-space:nowrap} +.ds-tb-icon{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--ink-3);border:1px solid var(--line-2);background:var(--card);box-shadow:var(--sh-card)} +.ds-kbd{font-family:var(--mono);font-size:10px;color:var(--ink-4);background:var(--sunken);border-radius:4px;padding:1px 4px} + +/* ---------- page head ---------- */ +.ds-pagehead{display:flex;align-items:flex-start;gap:16px 20px;flex-wrap:wrap;padding:18px 24px 14px;flex:none} +.ds-pagehead-txt{flex:1 1 300px;min-width:300px} +.ds-eyebrow{font-size:10.5px;font-weight:500;color:var(--ink-3);font-family:var(--mono);letter-spacing:.04em;margin-bottom:5px} +.ds-eyebrow.ds-accent{color:var(--green-text)} +.ds-pagehead h1{font-size:25px;font-weight:600;letter-spacing:-.035em;line-height:1.15;white-space:nowrap} +.ds-subtitle{margin-top:4px;font-size:12.5px;color:var(--ink-3)} +.ds-subtitle code{font-family:var(--mono);font-size:11.5px;color:var(--ink-2);background:var(--sunken);padding:1px 5px;border-radius:5px} +.ds-headtools{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end} +.ds-scroll{flex:1;min-height:0;padding:0 24px 24px;display:flex;flex-direction:column;gap:14px} +.ds-scroll.ds-tight{gap:12px} + +/* ---------- controls ---------- */ +.ds-pills{display:flex;align-items:center;gap:4px} +.ds-pill{height:30px;padding:0 12px;border-radius:999px;font-size:12.5px;font-weight:500;color:var(--ink-2);display:inline-flex;align-items:center;gap:6px;white-space:nowrap} +.ds-pill.ds-is-active{background:var(--ink);color:#fff} +.ds-pill .ds-count{font-family:var(--mono);font-size:10.5px;opacity:.6} +.ds-ctl{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 11px;border:1px solid var(--line-2);border-radius:var(--r-ctl);background:var(--card);font-size:12.5px;font-weight:500;color:var(--ink);box-shadow:var(--sh-card);white-space:nowrap} +.ds-ctl svg{color:var(--ink-3)} +.ds-ctl-key{color:var(--ink-3);font-weight:400} +.ds-ctl.ds-ghost{border-color:transparent;background:transparent;box-shadow:none;color:var(--ink-2)} +.ds-btn-primary{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 14px;border-radius:var(--r-ctl);background:var(--green);color:var(--green-ink);font-size:12.5px;font-weight:600;white-space:nowrap} +.ds-btn-dark{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 14px;border-radius:var(--r-ctl);background:var(--ink);color:#fff;font-size:12.5px;font-weight:500;white-space:nowrap} +.ds-btn-danger{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:var(--r-ctl);background:var(--red-soft);color:var(--red-text);font-size:12.5px;font-weight:500;box-shadow:inset 0 0 0 1px var(--red-line)} +.ds-seg{display:inline-flex;align-items:center;gap:2px;padding:3px;border-radius:var(--r-ctl);background:var(--sunken)} +.ds-seg-item{height:26px;padding:0 11px;border-radius:7px;font-size:12px;font-weight:500;color:var(--ink-2)} +.ds-seg-item.ds-is-active{background:#fff;color:var(--ink);box-shadow:0 0 0 1px rgba(20,22,20,.06),0 1px 2px rgba(20,22,20,.07)} +.ds-inp{width:100%;height:32px;padding:0 11px;border:1px solid var(--line-2);border-radius:var(--r-ctl);background:#fff;font-size:12.5px;color:var(--ink);box-shadow:var(--sh-card)} +.ds-inp.ds-mono{font-family:var(--mono);font-size:11.5px} +.ds-inp[disabled]{background:var(--sunken);color:var(--ink-3);box-shadow:none} +select.ds-inp{appearance:none;padding-right:28px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%236b726e' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;background-size:11px} +.ds-search{position:relative;display:flex;align-items:center} +.ds-search svg{position:absolute;left:10px;color:var(--ink-3)} +.ds-search .ds-inp{padding-left:30px} +.ds-switch{width:36px;height:21px;border-radius:999px;background:var(--line-3);position:relative;flex:none;display:inline-block} +.ds-switch i{position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(20,22,20,.25)} +.ds-switch.ds-on{background:var(--green-600)} +.ds-switch.ds-on i{left:auto;right:2px} +.ds-cbox{width:15px;height:15px;border-radius:4px;border:1px solid var(--line-3);background:#fff;display:inline-grid;place-items:center;color:#fff;flex:none} +.ds-cbox.ds-on{background:var(--ink);border-color:var(--ink)} +.ds-cbox svg{opacity:0}.ds-cbox.ds-on svg{opacity:1} +.ds-stepper{display:inline-flex;align-items:center;width:100%;border:1px solid var(--line-2);border-radius:var(--r-ctl);background:#fff;box-shadow:var(--sh-card);overflow:hidden} +.ds-stepper input{flex:1;min-width:0;height:30px;border:0;padding:0 10px;background:none;font-family:var(--mono);font-size:12px;outline:none} +.ds-stepper button{width:28px;height:30px;display:grid;place-items:center;color:var(--ink-3);border-left:1px solid var(--line)} +.ds-slider{display:flex;align-items:center;width:100%} +.ds-slider-track{flex:1;height:4px;border-radius:999px;background:var(--line-2);position:relative} +.ds-slider-track i{position:absolute;top:0;bottom:0;left:0;border-radius:999px;background:var(--green-600)} +.ds-slider-track b{position:absolute;top:50%;width:13px;height:13px;margin:-6.5px 0 0 -6.5px;border-radius:999px;background:#fff;box-shadow:0 0 0 1px var(--line-3),0 1px 3px rgba(20,22,20,.25)} +.ds-iconbtn{width:32px;height:32px;flex:none;border:1px solid var(--line-2);border-radius:var(--r-ctl);background:#fff;display:grid;place-items:center;color:var(--ink-3);box-shadow:var(--sh-card)} +.ds-kebab{width:26px;height:26px;border-radius:7px;display:inline-grid;place-items:center;color:var(--ink-3)} + +/* ---------- cards & common blocks ---------- */ +.ds-card{background:var(--card);border:1px solid var(--line);border-radius:var(--r-card);box-shadow:var(--sh-card)} +.ds-card.ds-flat{box-shadow:none} +.ds-card-head{display:flex;align-items:flex-start;gap:14px;padding:15px 17px 0} +.ds-card-head.ds-pad{padding-bottom:13px} +.ds-card-title{font-size:14px;font-weight:600;letter-spacing:-.02em} +.ds-card-sub{font-size:11.5px;color:var(--ink-3);margin-top:3px} +.ds-card-tools{margin-left:auto;display:flex;align-items:center;gap:7px;flex-wrap:wrap;justify-content:flex-end} +.ds-card-body{padding:14px 17px 16px} +.ds-cap{font-size:10.5px;font-weight:500;color:var(--ink-3);letter-spacing:.05em;text-transform:uppercase;font-family:var(--mono)} +.ds-badge{display:inline-flex;align-items:center;gap:5px;height:21px;padding:0 9px;border-radius:999px;font-size:11px;font-weight:500;white-space:nowrap} +.ds-badge.ds-ok{background:var(--green-soft);color:var(--green-text)} +.ds-badge.ds-err{background:var(--red-soft);color:var(--red-text)} +.ds-badge.ds-warn{background:var(--amber-soft);color:var(--amber-text)} +.ds-badge.ds-info{background:var(--blue-soft);color:var(--blue-text)} +.ds-badge.ds-mute{background:var(--sunken);color:var(--ink-2)} +.ds-tag{display:inline-flex;align-items:center;gap:4px;height:19px;padding:0 7px;border-radius:5px;font-size:10.5px;font-weight:500} +.ds-tag.ds-auto{background:var(--amber-soft);color:var(--amber-text)} +.ds-tag.ds-gate{background:var(--blue-soft);color:var(--blue-text)} +.ds-tag.ds-adv{background:var(--sunken);color:var(--ink-3)} +.ds-tag.ds-edited{background:var(--green-soft);color:var(--green-text)} +.ds-chip{display:inline-flex;align-items:center;gap:7px;height:27px;padding:0 6px 0 11px;border-radius:999px;background:var(--sunken);font-size:11.5px;color:var(--ink)} +.ds-chip b{font-family:var(--mono);font-weight:500;font-size:10.5px;color:var(--ink-3)} +.ds-chip.ds-is-active{background:var(--ink);color:#fff} +.ds-chip.ds-is-active b{color:rgba(255,255,255,.65)} +.ds-chip-x{width:16px;height:16px;border-radius:999px;display:grid;place-items:center;color:var(--ink-3)} +.ds-chip-add{height:27px;padding:0 11px;border-radius:999px;border:1px dashed var(--line-3);color:var(--ink-3);font-size:11.5px} +.ds-mono{font-family:var(--mono)} +.ds-num{font-family:var(--mono);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right} +.ds-muted{color:var(--ink-3)} + +/* ---------- tables ---------- */ +.ds-tbl th{font-size:11px;font-weight:400;color:var(--ink-3);text-align:left;padding:9px 10px;border-bottom:1px solid var(--line)} +.ds-tbl th:first-child,.ds-tbl td:first-child{padding-left:17px} +.ds-tbl th:last-child,.ds-tbl td:last-child{padding-right:17px;text-align:right} +.ds-tbl td{padding:10px;border-bottom:1px solid var(--line);font-size:12.5px;letter-spacing:-.01em;vertical-align:middle} +.ds-tbl tr:last-child td{border-bottom:0} +.ds-cell-main{display:flex;align-items:center;gap:10px} +.ds-cell-key{font-family:var(--mono);font-size:10.5px;color:var(--ink-3);margin-top:1px;display:block} +.ds-sect-icon{width:22px;height:22px;border-radius:7px;background:var(--sunken);display:grid;place-items:center;color:var(--ink-2);flex:none} + +/* ---------- KPI ---------- */ +.ds-kpis{display:grid;gap:14px} +.ds-kpi{position:relative;padding:15px 17px 16px;overflow:hidden} +.ds-kpi-top{display:flex;align-items:flex-start} +.ds-kpi-icon{width:29px;height:29px;border-radius:var(--r-tile);border:1px solid var(--line-2);display:grid;place-items:center;color:var(--ink-2);background:#fff} +.ds-kpi-spark{position:absolute;right:14px;top:13px;width:136px;height:52px} +.ds-delta{display:inline-flex;align-items:center;gap:3px;height:20px;padding:0 8px;border-radius:999px;font-size:11px;font-weight:500;position:relative;z-index:1} +.ds-delta.ds-up{background:var(--green-soft);color:var(--green-text);box-shadow:inset 0 0 0 1px var(--green-line)} +.ds-delta.ds-down{background:var(--red-soft);color:var(--red-text);box-shadow:inset 0 0 0 1px var(--red-line)} +.ds-delta.ds-mute{background:var(--sunken);color:var(--ink-2);box-shadow:inset 0 0 0 1px var(--line-2)} +.ds-kpi-val{margin-top:20px;font-size:27px;font-weight:600;letter-spacing:-.04em;line-height:1;font-variant-numeric:tabular-nums} +.ds-kpi-val small{font-size:14px;font-weight:500;color:var(--ink-3);letter-spacing:-.02em;margin-left:3px} +.ds-kpi-label{margin-top:6px;font-size:12px;color:var(--ink-3)} + +/* ---------- funnel / meters ---------- */ +.ds-frow{display:flex;align-items:center;gap:11px;height:34px;font-size:12px} +.ds-frow-info{width:14px;height:14px;color:var(--ink-3);flex:none} +.ds-frow-label{flex:none} +.ds-frow-pct{width:40px;flex:none;text-align:right;font-family:var(--mono);font-size:11px;color:var(--ink-3)} +.ds-frow-n{width:52px;flex:none;text-align:right;font-family:var(--mono);font-size:11.5px;font-variant-numeric:tabular-nums} +.ds-frow-track{flex:1;min-width:0;display:flex;align-items:center;gap:10px} +.ds-bar{height:10px;border-radius:3px;background:var(--line-2);flex:none} +.ds-bar.ds-hatch{background:repeating-linear-gradient(115deg,#f3f3f1 0 5px,#e7e7e4 5px 7px);box-shadow:inset 0 0 0 1px var(--line-2)} +.ds-bar.ds-green{background:var(--green)} +.ds-drop{font-size:11px;white-space:nowrap} +.ds-drop.ds-red{color:#b3402f}.ds-drop.ds-amber{color:#87680f}.ds-drop.ds-mute{color:var(--ink-3)} +.ds-meter{height:6px;border-radius:999px;background:var(--sunken);overflow:hidden} +.ds-meter i{display:block;height:100%;border-radius:999px;background:var(--green-600)} +.ds-meter.ds-thin{height:4px} +.ds-meter i.ds-warn{background:#d3a53a} + +/* ---------- thumbs ---------- */ +.ds-grid-thumbs{display:grid;gap:8px} +.ds-thumb{position:relative;border-radius:10px;background:var(--sunken);overflow:hidden;background-image:repeating-linear-gradient(135deg,rgba(20,22,20,.04) 0 8px,transparent 8px 16px);display:grid;place-items:center;color:var(--ink-4);font-family:var(--mono);font-size:10px} +.ds-thumb.ds-sel{box-shadow:0 0 0 2px var(--green-600),0 0 0 4px var(--green-soft)} +.ds-thumb .ds-mark{position:absolute;top:6px;left:6px} +.ds-thumb .ds-cap{position:absolute;left:0;right:0;bottom:0;padding:4px 6px;background:linear-gradient(transparent,rgba(20,22,20,.55));color:#fff;font-size:9.5px;text-transform:none;letter-spacing:0} +.ds-thumb .ds-pin{position:absolute;top:6px;right:6px;height:17px;padding:0 6px;border-radius:999px;background:rgba(255,255,255,.9);font-size:9.5px;font-weight:600;color:var(--ink-2);display:flex;align-items:center} + +/* ---------- misc blocks ---------- */ +.ds-tabs{display:flex;align-items:center;gap:18px;border-bottom:1px solid var(--line);padding:0 17px} +.ds-tab{height:38px;display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink-3);border-bottom:2px solid transparent;margin-bottom:-1px} +.ds-tab.ds-is-active{color:var(--ink);font-weight:500;border-bottom-color:var(--ink)} +.ds-console{background:#15171a;border-radius:12px;padding:12px 14px;font-family:var(--mono);font-size:11px;line-height:1.75;color:#c8cec7;overflow:hidden} +.ds-console .ds-t{color:#6f7a72} +.ds-console .ds-g{color:#a9d97a} +.ds-console .ds-w{color:#e0bd6a} +.ds-console .ds-e{color:#e08b7f} +.ds-yaml{margin:0;padding:14px 17px 16px;font-family:var(--mono);font-size:11.5px;line-height:1.7;color:var(--ink-2);overflow:hidden} +.ds-yaml .ds-k{color:#39619c}.ds-yaml .ds-v{color:var(--ink)}.ds-yaml .ds-s{color:#4a7429}.ds-yaml .ds-c{color:var(--ink-3)} +.ds-empty{border:1px dashed var(--line-3);border-radius:12px;padding:26px;display:flex;flex-direction:column;align-items:center;gap:6px;color:var(--ink-3);font-size:12.5px;text-align:center} +.ds-note{display:flex;gap:9px;padding:11px 13px;border-radius:12px;font-size:12px;line-height:1.5} +.ds-note.ds-warn{background:var(--amber-soft);color:var(--amber-text);box-shadow:inset 0 0 0 1px var(--amber-line)} +.ds-note.ds-info{background:var(--blue-soft);color:var(--blue-text);box-shadow:inset 0 0 0 1px var(--blue-line)} +.ds-note.ds-ok{background:var(--green-soft);color:var(--green-text);box-shadow:inset 0 0 0 1px var(--green-line)} +.ds-kv{display:flex;align-items:baseline;justify-content:space-between;gap:12px;font-size:12px;padding:3px 0} +.ds-kv .ds-k{color:var(--ink-3)} +.ds-kv .ds-v{font-family:var(--mono);font-size:11.5px;color:var(--ink)} +.ds-kv .ds-v.ds-acc{color:var(--green-text);font-weight:600} +.ds-split{display:flex;gap:14px;min-height:0} +.ds-railnav{display:flex;flex-direction:column;gap:2px} +.ds-railnav a{height:28px;display:flex;align-items:center;padding:0 9px;border-radius:8px;font-size:12px;color:var(--ink-3)} +.ds-railnav a.ds-is-active{background:var(--sunken);color:var(--ink);font-weight:500} +.ds-field{display:flex;align-items:flex-start;gap:18px;padding:13px 17px;border-bottom:1px solid var(--line)} +.ds-field:last-child{border-bottom:0} +.ds-field-txt{flex:1;min-width:0} +.ds-field-name{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.ds-field-name .ds-label{font-size:13px;font-weight:500;letter-spacing:-.015em} +.ds-field-name .ds-key{font-family:var(--mono);font-size:10.5px;color:var(--ink-3)} +.ds-field-desc{font-size:11.5px;color:var(--ink-3);margin-top:4px;line-height:1.5;max-width:66ch} +.ds-field-rec{font-size:11px;color:var(--green-text);margin-top:4px} +.ds-field-ctl{width:224px;flex:none;display:flex;flex-direction:column;gap:6px;align-items:flex-end} +.ds-ctl-note{font-size:10.5px;color:var(--ink-3);font-family:var(--mono)} +.ds-ctl-note a{color:var(--green-text);text-decoration:underline;text-underline-offset:2px} +.ds-diff{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px} +.ds-diff .ds-from{color:var(--ink-3);text-decoration:line-through} +.ds-diff .ds-to{color:var(--ink)} +.ds-logbar{height:34px;flex:none;border-top:1px solid var(--line);background:var(--panel);display:flex;align-items:center;gap:10px;padding:0 20px;font-size:11.5px;color:var(--ink-3)} +.ds-drawer{position:absolute;top:0;right:0;bottom:0;width:560px;background:var(--panel);border-left:1px solid var(--line-2);box-shadow:-24px 0 48px -24px rgba(20,22,20,.22);display:flex;flex-direction:column} +.ds-scrim{position:absolute;inset:0;background:rgba(20,22,20,.18)} + +/* layout guards */ +.ds-card div,.ds-card span,.ds-card input,.ds-card select{min-width:0} +.ds-thumb .ds-cap{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-drawer .ds-field{flex-direction:column;gap:9px} +.ds-drawer .ds-field-ctl{width:100%;align-items:stretch} +.ds-drawer .ds-field-desc{max-width:none} + +/* ===== v2: sidebar project block ===== */ +.ds-projcard{display:flex;align-items:center;gap:10px;width:100%;padding:8px 9px;border:1px solid var(--line-2);border-radius:12px;background:var(--card);box-shadow:var(--sh-card);margin:2px 0 8px} +.ds-projcard-tile{width:30px;height:30px;border-radius:9px;background:var(--green);display:grid;place-items:center;color:var(--green-ink);flex:none} +.ds-projcard-txt{flex:1;min-width:0} +.ds-projcard-name{font-size:13px;font-weight:600;letter-spacing:-.015em;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-projcard-slug{font-family:var(--mono);font-size:10.5px;color:var(--ink-3);display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-verbar{display:flex;align-items:center;gap:8px;padding:10px 9px 7px} +.ds-verbar .ds-cap{flex:none} +.ds-verpill{display:inline-flex;align-items:center;gap:7px;height:25px;padding:0 8px;border-radius:999px;background:var(--sunken);font-family:var(--mono);font-size:11.5px;color:var(--ink);min-width:0} +.ds-verpill span.ds-nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.ds-verpill svg{color:var(--ink-3);flex:none} + +.ds-steps{display:flex;flex-direction:column;position:relative} +.ds-step{display:flex;align-items:center;gap:11px;height:34px;padding:0 9px 0 7px;border-radius:10px;font-size:12.5px;color:var(--ink-2);position:relative;letter-spacing:-.01em} +.ds-step::before{content:"";position:absolute;left:14.2px;top:0;bottom:0;width:1.5px;background:var(--line-2)} +.ds-step:first-child::before{top:17px} +.ds-step:last-child::before{bottom:17px} +.ds-step.ds-is-done::before,.ds-step.ds-is-done+.ds-step::before{background:var(--green-line)} +.ds-step-node{width:16px;height:16px;border-radius:999px;border:1.5px solid var(--line-3);background:var(--card);flex:none;display:grid;place-items:center;position:relative;z-index:1;color:#fff} +.ds-step.ds-is-done .ds-step-node{background:var(--green-600);border-color:var(--green-600)} +.ds-step.ds-is-cur .ds-step-node{background:var(--ink);border-color:var(--ink);font-size:9px;font-family:var(--mono);font-weight:600} +.ds-step-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-step-tail{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);flex:none} +.ds-step.ds-is-active{background:var(--sunken);color:var(--ink);font-weight:600} +.ds-step.ds-is-active .ds-step-tail{color:var(--ink-3)} +.ds-step.ds-is-opt .ds-step-label{color:var(--ink-3)} +.ds-step.ds-is-active.ds-is-opt .ds-step-label{color:var(--ink)} + +/* ===== v2: browser-style tabs ===== */ +.ds-btabs{display:flex;align-items:flex-end;gap:3px;padding:0 10px} +.ds-btab{height:33px;padding:0 12px;border-radius:10px 10px 0 0;display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--ink-3);white-space:nowrap;border:1px solid transparent;position:relative} +.ds-btab .ds-dotn{width:5px;height:5px;border-radius:999px;background:var(--green-600);flex:none} +.ds-btab.ds-is-active{background:var(--card);color:var(--ink);font-weight:600;border-color:var(--line);border-bottom-color:var(--card);margin-bottom:-1px;z-index:1} +.ds-btab.ds-more{color:var(--ink-4)} +.ds-tabcard{border-top-left-radius:0;border-top-right-radius:0} + +/* ===== v2: misc ===== */ +.ds-kpi-meta{font-size:11px;color:var(--ink-3);line-height:1.45} +.ds-bulkbar{display:flex;align-items:center;gap:9px;padding:8px 17px;background:var(--green-soft);border-top:1px solid var(--green-line);border-bottom:1px solid var(--green-line);font-size:12px;color:var(--green-text)} +.ds-gate-note{font-size:11px;color:var(--ink-3);margin-top:4px;display:flex;align-items:center;gap:5px} +.ds-gate-note svg{flex:none;opacity:.7} +.ds-lockchip{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;color:var(--amber-text);background:var(--amber-soft);height:18px;padding:0 6px;border-radius:5px} +.ds-changed-dot{width:5px;height:5px;border-radius:999px;background:var(--green-600);flex:none} +.ds-pane-split{display:grid;grid-template-columns:1fr 392px} +.ds-pane-right{border-left:1px solid var(--line);display:flex;flex-direction:column;min-width:0} +.ds-pane-head{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--line)} + +/* ===== v3 ===== */ +/* баг: .meter как одиночный inline-span схлопывался в ноль (Обзор, Задача) */ +.ds-meter{display:block} +.ds-meter.ds-inline{display:inline-block;vertical-align:middle} + +/* вкладки: у неактивных своя подложка, лента стоит на рельсе */ +.ds-btabs{gap:4px;padding:0 10px;position:relative;z-index:1} +.ds-btab{height:30px;background:var(--sunken);border:1px solid var(--line-2);border-bottom-color:transparent;color:var(--ink-2)} +.ds-btab:first-child{border-top-left-radius:10px} +.ds-btab.ds-is-active{height:36px;background:var(--card);border-color:var(--line);border-bottom-color:var(--card);color:var(--ink);font-weight:600;margin-bottom:-1px;box-shadow:0 -3px 8px -6px rgba(20,22,20,.25)} +.ds-btab.ds-more{background:transparent;border-color:transparent;color:var(--ink-3)} +.ds-tabcard{margin-top:-1px} + +/* плотная сетка KPI */ +.ds-kpis.ds-tight{gap:10px} +.ds-kpis.ds-tight .ds-kpi{padding:13px 15px 14px} + +/* мини-графики */ +.ds-barset{display:flex;align-items:flex-end;gap:4px;height:46px} +.ds-barset i{flex:1;border-radius:3px 3px 0 0;background:var(--green);display:block} +.ds-barset i.ds-mute{background:var(--line-2)} +.ds-legend{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--ink-3)} +.ds-legend s{width:8px;height:8px;border-radius:2px;display:inline-block;text-decoration:none} +.ds-donut{width:76px;height:76px;flex:none} +.ds-statgrid{display:grid;gap:0} +.ds-statgrid > div{padding:13px 16px;border-right:1px solid var(--line)} +.ds-statgrid > div:last-child{border-right:0} +.ds-stat-v{font-size:19px;font-weight:600;letter-spacing:-.03em;margin-top:5px;font-variant-numeric:tabular-nums} + +/* полоса загрузки файлов */ +.ds-dropstrip{display:flex;align-items:center;gap:10px;padding:11px 14px;border:1px dashed var(--line-3);border-radius:12px;background:var(--area);font-size:12px;color:var(--ink-3)} +.ds-dropstrip b{font-weight:500;color:var(--ink-2)} + +/* компактная группа действий в строке списка */ +.ds-actgroup{display:inline-flex;align-items:center;border:1px solid var(--line-2);border-radius:9px;background:var(--card);box-shadow:var(--sh-card);overflow:hidden} +.ds-actgroup button,.ds-actgroup a{height:28px;padding:0 10px;font-size:11.5px;font-weight:500;color:var(--ink-2);display:inline-flex;align-items:center;gap:6px;border-right:1px solid var(--line)} +.ds-actgroup button:last-child,.ds-actgroup a:last-child{border-right:0} +.ds-actgroup .ds-ico{width:30px;justify-content:center;padding:0;color:var(--ink-3)} + +/* размеры ввода Ш×В */ +.ds-wh{display:flex;align-items:center;gap:6px} +.ds-wh .ds-stepper{width:92px} +.ds-wh .ds-x{font-size:11px;color:var(--ink-3)} + +.ds-card{min-width:0} +.ds-card div,.ds-card span,.ds-card input,.ds-card select{min-width:0} + +/* панель фильтров страницы: поиск тянется в упор, переключатель прижат вправо */ +.ds-toolbar{display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--card);border:1px solid var(--line);border-radius:var(--r-card);box-shadow:var(--sh-card)} +.ds-toolbar .ds-search{min-width:0} +.ds-toolbar .ds-search .ds-inp{height:34px;padding-left:33px;background:var(--area);box-shadow:none} + +/* второстепенная полоса опций внутри карточки */ +.ds-optstrip{display:flex;align-items:center;gap:11px;padding:9px 12px;border:1px solid var(--line-2);border-radius:10px;background:var(--area)} + +/* подписи под столбиковой диаграммой */ +.ds-axis{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:4px;text-align:center;margin-top:7px;font-size:10px;color:var(--ink-3);font-family:var(--mono)} + +/* заголовок подгруппы схемы внутри тематической вкладки */ +.ds-subgroup{display:flex;align-items:center;gap:9px;padding:9px 17px;background:var(--sunken);border-top:1px solid var(--line);border-bottom:1px solid var(--line)} +.ds-subgroup:first-child{border-top:0} +.ds-subgroup .ds-sg-name{font-size:11.5px;font-weight:600;letter-spacing:-.01em;color:var(--ink-2)} +.ds-subgroup .ds-sg-key{font-family:var(--mono);font-size:10.5px;color:var(--ink-3)} + +/* шапка конфига версии */ +.ds-cfgbar{display:flex;align-items:center;gap:12px;padding:11px 14px;background:var(--card);border:1px solid var(--line);border-radius:var(--r-card);box-shadow:var(--sh-card)} +.ds-cfgbar .ds-cfg-name{font-size:13.5px;font-weight:600;letter-spacing:-.02em;display:block} +.ds-cfgbar .ds-cfg-sub{font-size:11px;color:var(--ink-3);margin-top:2px;display:block} + +/* вкладки: ровная полоса, без переносов */ +.ds-tabs{flex-wrap:nowrap;overflow:hidden} +.ds-tab{white-space:nowrap} + +/* ── v5 ───────────────────────────────────────────────────────────────────── */ + +/* трекинг −1% по всему макету; у крупных заголовков свои, более тугие значения */ +.ds-app{letter-spacing:-.01em} + +/* счётчик внутри чипа — такой же пилюлей, как badge во вкладках */ +.ds-chip{padding:0 5px 0 11px} +.ds-chip b{font-family:var(--mono);font-weight:500;font-size:10px;line-height:1;color:var(--ink-3);background:rgba(20,22,20,.06);border-radius:999px;padding:3px 6px} +.ds-chip.ds-is-active b{background:rgba(255,255,255,.2);color:rgba(255,255,255,.85)} +.ds-chip.ds-is-active .ds-chip-x{color:rgba(255,255,255,.7)} + +/* метка на миниатюре: тёмная пилюля, не вылезает за кадр */ +.ds-thumb .ds-pin{top:6px;right:6px;height:18px;padding:0 7px;background:rgba(20,22,20,.72);color:#fff;font-size:9.5px;font-weight:500;letter-spacing:0;white-space:nowrap;max-width:calc(100% - 12px);overflow:hidden} + +/* сегмент в панели фильтров — одной высоты со строкой поиска */ +.ds-toolbar .ds-seg{height:34px;padding:3px} +.ds-toolbar .ds-seg-item{height:28px} + +/* нагрузка на систему в топбаре: GPU · VRAM · CPU · RAM */ +.ds-sysload{display:flex;align-items:center;gap:11px;height:30px;padding:0 11px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);box-shadow:var(--sh-card)} +.ds-sysload > span{display:flex;align-items:center;gap:5px;white-space:nowrap} +.ds-sysload .ds-lbl{font-style:normal;font-size:9px;font-weight:600;letter-spacing:.06em;color:var(--ink-4)} +.ds-sysload b{font-family:var(--mono);font-size:10.5px;font-weight:500;color:var(--ink-2)} +.ds-sysload .ds-bar{width:20px;height:4px;border-radius:999px;background:var(--sunken);overflow:hidden;display:block} +.ds-sysload .ds-bar i{display:block;height:100%;border-radius:999px;background:var(--green-600)} +.ds-sysload .ds-bar i.ds-hot{background:#d3a53a} + +/* строка очереди: у всех задач одна сетка колонок */ +.ds-qrow{display:grid;grid-template-columns:14px minmax(0,1fr) 246px 92px auto;align-items:center;gap:14px;padding:12px 15px;border:1px solid var(--line);border-radius:var(--r-tile);background:var(--card)} +.ds-qrow.ds-is-run{border-color:var(--green-line);background:#fbfdf7} +.ds-qrow.ds-is-done{opacity:.72} +.ds-qdrag{color:var(--ink-4);display:flex;align-items:center;justify-content:center} +.ds-qname{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500} +.ds-qmeta{display:block;margin-top:4px;font-family:var(--mono);font-size:11px;color:var(--ink-3)} +.ds-qprog{display:flex;flex-direction:column;gap:6px} +.ds-qprog .ds-mono{font-size:11px} +.ds-qprog-t{display:flex;align-items:center;justify-content:space-between;color:var(--ink-3)} +.ds-qprog-t b{color:var(--ink);font-weight:500} +.ds-qeta{text-align:right} +.ds-qeta b{display:block;font-size:12.5px;font-weight:600;letter-spacing:-.02em} +.ds-qeta span{display:block;margin-top:2px;font-size:10.5px;color:var(--ink-3);font-family:var(--mono)} +.ds-qnote{grid-column:2 / 5;display:flex;align-items:center;gap:7px;margin-top:2px;font-size:11.5px;color:var(--ink-2)} + +/* вложенные карточки внутри тела вкладки */ +.ds-tabbody{flex:1;min-height:0;background:var(--area);padding:14px;display:grid;gap:14px;align-items:start;overflow:hidden} + +/* ── v6 ───────────────────────────────────────────────────────────────────── */ + +/* во всех таблицах выравнивание по левому краю — включая числовые колонки */ +.ds-num{text-align:left} +.ds-tbl th:last-child,.ds-tbl td:last-child{text-align:left} + +/* счётчик во вкладке — меньше и ровно по базовой линии */ +.ds-tab .ds-badge{height:16px;padding:0 5px;font-size:10px;border-radius:5px;letter-spacing:0} + +/* нагрузка на систему: разделители между типами */ +.ds-sysload{gap:0} +.ds-sysload > span{padding:0 10px;border-left:1px solid var(--line)} +.ds-sysload > span:first-child{padding-left:0;border-left:0} +.ds-sysload > span:last-child{padding-right:0} + +/* строка очереди: колонки одинаковые при любом статусе */ +.ds-qrow{grid-template-columns:14px minmax(0,1fr) 246px 96px 172px} +.ds-qrow .ds-actgroup{justify-self:end} + +/* описания полей — чуть плотнее */ +.ds-field-desc{line-height:1.45} + +/* единый поиск с источником: один объект, а не три контрола подряд */ +.ds-searchgroup{display:flex;align-items:stretch;height:36px;border:1px solid var(--line-2);border-radius:var(--r-ctl);background:#fff;box-shadow:var(--sh-card);overflow:hidden} +.ds-searchgroup > select{width:132px;flex:none;height:100%;border:0;border-right:1px solid var(--line-2);border-radius:0;background:var(--sunken);box-shadow:none;padding:0 10px} +.ds-searchgroup .ds-q{position:relative;flex:1;min-width:0;display:flex;align-items:center} +.ds-searchgroup .ds-q > svg{position:absolute;left:11px;color:var(--ink-3)} +.ds-searchgroup .ds-q > input{height:100%;border:0;border-radius:0;box-shadow:none;background:transparent;padding-left:32px} +.ds-searchgroup > button{flex:none;padding:0 16px;background:var(--ink);color:#fff;font-size:12.5px;font-weight:500;letter-spacing:-.01em} + +/* строка версии в списке: действие на одной линии с названием */ +.ds-verrow{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:11px 17px;border-bottom:1px solid var(--line)} +.ds-verrow:last-child{border-bottom:0} +.ds-verrow.ds-is-active{background:var(--green-soft)} +.ds-verrow-name{display:flex;align-items:center;gap:8px} +.ds-verrow-name .ds-nm{font-family:var(--mono);font-size:12.5px;font-weight:600} + +/* карточка предпросмотра рядом с формой — отдельным блоком, без разделительной линии */ +.ds-formsplit{display:grid;grid-template-columns:minmax(0,1fr) 392px;gap:14px;align-items:start} +.ds-formsplit.ds-even{grid-template-columns:minmax(0,1fr) minmax(0,1fr)} + +/* шаги версии выровнены по иконкам обычных пунктов меню */ +.ds-step{padding:0 9px 0 8.5px;gap:9.5px} +.ds-step::before{left:15.75px} + +/* две карточки одной высоты (форма и журнал рядом) */ +.ds-formsplit.ds-fill{align-items:stretch} + +/* мелочи после сверки */ +.ds-searchgroup .ds-q > input{font-size:12.5px;color:var(--ink);letter-spacing:-.01em} +.ds-searchgroup .ds-q > input::placeholder{color:var(--ink-4)} +.ds-qeta{text-align:left} + +/* ── v7 ───────────────────────────────────────────────────────────────────── */ + +/* текст в кнопках по центру: растянутые кнопки наследовали button{text-align:left} */ +.ds-seg-item,.ds-pill,.ds-tab,.ds-chip-add,.ds-btn-primary,.ds-btn-dark,.ds-btn-danger,.ds-ctl, +.ds-actgroup button,.ds-actgroup a,.ds-searchgroup > button{justify-content:center;text-align:center} +.ds-seg-item,.ds-chip-add{display:inline-flex;align-items:center} + +/* ни одной кнопки «в воздухе»: у призрачных та же рамка, что у обычных */ +.ds-ctl.ds-ghost{border-color:var(--line-2);background:var(--card);box-shadow:var(--sh-card);color:var(--ink-2)} + +/* разделители нагрузки в топбаре — во всю высоту блока */ +.ds-sysload{align-items:stretch} +.ds-sysload > span{align-items:center} + +/* сворачивание меню — иконкой в шапке, рядом с названием */ +.ds-brand-collapse{width:26px;height:26px;margin-left:auto;border-radius:7px;display:grid;place-items:center;color:var(--ink-3);flex:none} + +/* карточки выбора одного из вариантов: режим рантайма, источник reg-набора */ +.ds-optcards{display:grid;grid-template-columns:1fr 1fr;gap:9px} +.ds-optcard{display:flex;align-items:flex-start;gap:11px;width:100%;padding:12px 13px;border:1px solid var(--line-2);border-radius:12px;background:var(--card);box-shadow:var(--sh-card);text-align:left} +.ds-optcard.ds-is-on{border-color:var(--green-600);background:#fbfdf7;box-shadow:none} +.ds-optcard-ico{width:28px;height:28px;border-radius:9px;background:var(--sunken);display:grid;place-items:center;color:var(--ink-2);flex:none} +.ds-optcard.ds-is-on .ds-optcard-ico{background:var(--green);color:var(--green-ink)} +.ds-optcard-txt{flex:1;min-width:0} +.ds-optcard-name{display:flex;align-items:center;gap:7px;font-size:12.5px;font-weight:600;letter-spacing:-.015em} +.ds-optcard-name .ds-badge{font-weight:500} +.ds-optcard-desc{display:block;margin-top:4px;font-size:11.5px;color:var(--ink-3);line-height:1.45} +.ds-radio{width:16px;height:16px;border-radius:999px;border:1.5px solid var(--line-3);background:#fff;flex:none;margin-top:1px} +.ds-radio.ds-on{border:5px solid var(--green-600)} + +/* задача в очереди: шапка, полоса прогресса и заметка — тремя ярусами во всю ширину */ +.ds-qcard{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:var(--r-tile);background:var(--card);overflow:hidden} +.ds-qcard.ds-is-run{border-color:var(--green-line);background:#fbfdf7} +.ds-qcard.ds-is-done{opacity:.72} +.ds-qcard-top{display:grid;grid-template-columns:14px minmax(0,1fr) auto auto;align-items:center;gap:13px;padding:11px 15px} +.ds-qtitle{display:flex;align-items:center;gap:9px;min-width:0;font-size:13px;font-weight:500} +.ds-qtitle .ds-qid{font-family:var(--mono);font-size:11px;font-weight:400;color:var(--ink-3);white-space:nowrap} +.ds-qtime{display:flex;align-items:baseline;gap:7px;white-space:nowrap} +.ds-qtime b{font-size:12.5px;font-weight:600;letter-spacing:-.02em} +.ds-qtime i{font-style:normal;font-size:10.5px;color:var(--ink-3);font-family:var(--mono)} +.ds-qcard-bar{display:flex;align-items:center;gap:13px;padding:10px 15px;border-top:1px solid var(--line)} +.ds-qcard.ds-is-run .ds-qcard-bar{border-top-color:var(--green-line)} +.ds-qcard-bar .ds-meter{flex:1;min-width:0} +.ds-qcard-stats{flex:none;display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:11px;color:var(--ink-3)} +.ds-qcard-stats b{color:var(--ink);font-weight:500} +.ds-qcard-note{display:flex;align-items:center;gap:8px;padding:9px 15px;border-top:1px solid var(--line);font-size:11.5px;color:var(--ink-2);background:rgba(20,22,20,.02)} +.ds-qcard.ds-is-run .ds-qcard-note{border-top-color:var(--green-line);background:rgba(20,22,20,.015)} + +/* нижняя панель карточки: подсказка слева, действия справа — одинаково везде */ +.ds-cardfoot{display:flex;align-items:center;gap:10px;flex:none;padding:11px 17px;border-top:1px solid var(--line);background:var(--area);border-radius:0 0 var(--r-card) var(--r-card)} +.ds-cardfoot .ds-hint{display:flex;align-items:center;gap:7px;min-width:0;font-size:11.5px;color:var(--ink-3)} +.ds-cardfoot-act{margin-left:auto;display:flex;align-items:center;gap:8px} + +/* номер задачи в таблице — отдельной меткой, а не текстом впритык к названию */ +.ds-idchip{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:6px;background:var(--sunken);font-family:var(--mono);font-size:10.5px;color:var(--ink-3);flex:none} + +/* строка версии: открывается кликом по всей строке, действие — обведённой иконкой */ +.ds-verrow .ds-rowgo{width:28px;height:28px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);box-shadow:var(--sh-card);display:grid;place-items:center;color:var(--ink-3);flex:none} +.ds-verrow.ds-is-active .ds-rowgo{border-color:var(--green-line)} + +/* командная палитра — поиск из топбара по Ctrl K */ +.ds-palette{position:absolute;top:96px;left:50%;transform:translateX(-50%);width:640px;background:var(--panel);border:1px solid var(--line-2);border-radius:16px;box-shadow:0 24px 64px -20px rgba(20,22,20,.32),0 2px 8px rgba(20,22,20,.06);overflow:hidden;display:flex;flex-direction:column} +.ds-palette-q{display:flex;align-items:center;gap:10px;height:50px;padding:0 16px;border-bottom:1px solid var(--line)} +.ds-palette-q input{flex:1;min-width:0;border:0;outline:none;background:none;font-size:14px;letter-spacing:-.015em} +.ds-palette-sect{padding:9px 16px 5px} +.ds-palette-row{display:flex;align-items:center;gap:11px;padding:8px 16px;font-size:12.5px;color:var(--ink-2)} +.ds-palette-row.ds-is-on{background:var(--sunken);color:var(--ink)} +.ds-palette-ico{width:24px;height:24px;border-radius:7px;background:var(--sunken);display:grid;place-items:center;color:var(--ink-2);flex:none} +.ds-palette-row.ds-is-on .ds-palette-ico{background:#fff;box-shadow:0 0 0 1px var(--line-2)} +.ds-palette-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-palette-name em{font-style:normal;background:var(--green-soft);color:var(--green-text);border-radius:3px;padding:0 2px} +.ds-palette-kind{font-family:var(--mono);font-size:10.5px;color:var(--ink-4);flex:none} +.ds-palette-foot{display:flex;align-items:center;gap:14px;padding:9px 16px;border-top:1px solid var(--line);background:var(--area);font-size:11px;color:var(--ink-3)} +.ds-palette-foot span{display:flex;align-items:center;gap:5px} + +/* v7 · доводка после прогона */ +.ds-cardfoot .ds-hint{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ds-cardfoot .ds-hint .ds-badge,.ds-cardfoot .ds-hint .ds-kbd{flex:none} +.ds-cardfoot-act{flex:none} +.ds-drawer .ds-optcards{grid-template-columns:1fr} +.ds-qtitle{white-space:nowrap;overflow:hidden} + +/* v7 · строка запроса без селектора источника — источник живёт в шапке карточки */ +.ds-searchgroup .ds-q > input{padding-left:34px} +.ds-card-head .ds-cap{align-self:center} + +/* ── app adaptations of the mockup frame ───────────────────────────────── */ + +/* the artboard frame becomes the real viewport */ +.ds-viewport { height: 100dvh; background: var(--canvas); } +.ds-app { min-height: 0; grid-template-columns: auto minmax(0, 1fr); } +.ds-main { min-height: 0; } + +/* phone: full-bleed, the sidebar is an off-canvas drawer */ +@media (max-width: 768px) { + .ds-viewport { padding: 0; } + .ds-app { display: flex; border: 0; border-radius: 0; box-shadow: none; } + .ds-main { flex: 1; } +} + +/* the mockup reset left-aligned every button; keep that for the rows that + render as + {open && ( +
+ {items.map((it) => ( + + ))} +
+ )} +
+ ) +} diff --git a/studio/web/src/components/ds/PageHead.tsx b/studio/web/src/components/ds/PageHead.tsx new file mode 100644 index 0000000..5af1e00 --- /dev/null +++ b/studio/web/src/components/ds/PageHead.tsx @@ -0,0 +1,21 @@ +/** Page header from the mockup (.pagehead): mono eyebrow, 25px title, + * subtitle, tools on the right. */ +export default function PageHead({ eyebrow, accent = false, title, subtitle, tools }: { + eyebrow?: React.ReactNode + /** Green eyebrow, used on version steps ("ШАГ 3 · ВЕРСИЯ v3-lokr"). */ + accent?: boolean + title: React.ReactNode + subtitle?: React.ReactNode + tools?: React.ReactNode +}) { + return ( +
+
+ {eyebrow &&
{eyebrow}
} +

{title}

+ {subtitle &&
{subtitle}
} +
+ {tools &&
{tools}
} +
+ ) +} diff --git a/studio/web/src/i18n/locales/en.json b/studio/web/src/i18n/locales/en.json index 32e474e..e6e2def 100644 --- a/studio/web/src/i18n/locales/en.json +++ b/studio/web/src/i18n/locales/en.json @@ -747,7 +747,64 @@ "unarchiveConfirm": "Restore project \"{{title}}\" to the project list?", "unarchived": "Restored: {{title}}", "editProject": "Edit project", - "edited": "Saved \"{{title}}\"" + "edited": "Saved \"{{title}}\"", + "eyebrow": "PROJECTS", + "subtitle": "One project — one LoRA training goal: a character, a style or a concept.", + "filterAll": "All", + "filterActive": "Active", + "filterArchived": "Archive", + "noMatches": "Nothing found", + "kpiActive": "Active projects", + "kpiWeek": "+{{n}} this week", + "kpiTrainTasks": "Training tasks", + "kpiInQueue": "queued", + "kpiRunning": "{{n}} running", + "kpiGpu": "GPU hours, last 30 days", + "kpiGpuWeek": "last 7 days — {{h}} h", + "kpiHours": "h", + "kpiCkpts": "Finished LoRA checkpoints", + "images": "{{n}} imgs", + "versions_one": "{{count}} version", + "versions_other": "{{count}} versions", + "modelReady": "Model ready", + "stepOf": "Step {{n}} of 5 · {{step}}", + "waitingSlot": "waiting for a slot", + "doneAt": "Done {{when}}", + "startAt": "start ≈ {{time}}", + "ckpts_one": "{{count}} checkpoint", + "ckpts_other": "{{count}} checkpoints", + "continueStep": "Continue: {{step}}", + "continueQueue": "Continue: queue", + "viewResult": "View result", + "openProject": "Open project", + "actions": "Project actions", + "nowTitle": "What is happening right now", + "nowSub": "Queue, data jobs and evaluation — across all projects", + "nowAll": "Full queue", + "nowEmpty": "Nothing is running or waiting in the queue.", + "colTask": "Task", + "colProject": "Project", + "colStatus": "Status", + "colProgress": "Progress", + "colLeft": "Left", + "ahead_one": "{{count}} task ahead", + "ahead_other": "{{count}} tasks ahead", + "pausedAt": "paused at step {{step}}", + "leftMin": "~{{m}} min", + "leftHours": "~{{h}} h {{m}} m", + "openTask": "Open task", + "taskActions": "Actions", + "taskKind": { + "train": "Training", + "reg_ai": "Reg set build", + "reg_build": "Reg set build", + "download": "Booru scrape", + "preprocess": "Preprocess", + "tag": "Tagging", + "eval": "Checkpoint evaluation", + "upload": "Upload", + "generate": "Generation" + } }, "eval": { "evaluatingProgress": "Evaluating {{done}}/{{total}}", diff --git a/studio/web/src/i18n/locales/ru.json b/studio/web/src/i18n/locales/ru.json index 628a860..680aebb 100644 --- a/studio/web/src/i18n/locales/ru.json +++ b/studio/web/src/i18n/locales/ru.json @@ -497,7 +497,7 @@ "creating": "Создание…", "filters": "Фильтр / сортировка", "filtersActive": "Фильтры включены", - "searchPlaceholder": "Поиск по названию / слагу / заметкам…", + "searchPlaceholder": "Поиск по названию, слагу или заметке", "statusAll": "Все статусы", "sortLabel": "Сортировка", "sort_updated": "Недавно активные", @@ -518,7 +518,70 @@ "unarchiveConfirm": "Вернуть проект «{{title}}» в список?", "unarchived": "Возвращено: {{title}}", "editProject": "Изменить проект", - "edited": "Сохранено «{{title}}»" + "edited": "Сохранено «{{title}}»", + "eyebrow": "ПРОЕКТЫ", + "subtitle": "Один проект — одна цель тренировки LoRA: персонаж, стиль или концепт.", + "filterAll": "Все", + "filterActive": "Активные", + "filterArchived": "Архив", + "noMatches": "Ничего не найдено", + "kpiActive": "Активных проектов", + "kpiWeek": "+{{n}} за неделю", + "kpiTrainTasks": "Задачи тренировки", + "kpiInQueue": "в очереди", + "kpiRunning": "{{n}} выполняется", + "kpiGpu": "GPU-часов за 30 дней", + "kpiGpuWeek": "за 7 дней — {{h}} ч", + "kpiHours": "ч", + "kpiCkpts": "Готовых чекпоинтов LoRA", + "images": "{{n}} изобр.", + "versions_one": "{{count}} версия", + "versions_few": "{{count}} версии", + "versions_many": "{{count}} версий", + "versions_other": "{{count}} версии", + "modelReady": "Модель готова", + "stepOf": "Шаг {{n}} из 5 · {{step}}", + "waitingSlot": "ждёт слот", + "doneAt": "Готово {{when}}", + "startAt": "старт ≈ {{time}}", + "ckpts_one": "{{count}} чекпоинт", + "ckpts_few": "{{count}} чекпоинта", + "ckpts_many": "{{count}} чекпоинтов", + "ckpts_other": "{{count}} чекпоинта", + "continueStep": "Продолжить: {{step}}", + "continueQueue": "Продолжить: очередь", + "viewResult": "Посмотреть результат", + "openProject": "Открыть проект", + "actions": "Действия проекта", + "nowTitle": "Что происходит прямо сейчас", + "nowSub": "Очередь, задачи данных и оценка — по всем проектам", + "nowAll": "Вся очередь", + "nowEmpty": "Сейчас ничего не выполняется и не ждёт в очереди.", + "colTask": "Задача", + "colProject": "Проект", + "colStatus": "Статус", + "colProgress": "Прогресс", + "colLeft": "Осталось", + "ahead_one": "впереди {{count}} задача", + "ahead_few": "впереди {{count}} задачи", + "ahead_many": "впереди {{count}} задач", + "ahead_other": "впереди {{count}} задачи", + "pausedAt": "пауза на шаге {{step}}", + "leftMin": "~{{m}} мин", + "leftHours": "~{{h}} ч {{m}} м", + "openTask": "Открыть задачу", + "taskActions": "Действия", + "taskKind": { + "train": "Тренировка", + "reg_ai": "Сбор reg-набора", + "reg_build": "Сбор reg-набора", + "download": "Booru scrape", + "preprocess": "Препроцесс", + "tag": "Тегирование", + "eval": "Оценка чекпоинтов", + "upload": "Загрузка", + "generate": "Генерация" + } }, "overview": { "pipelineProgress": "Прогресс конвейера", diff --git a/studio/web/src/pages/Projects.tsx b/studio/web/src/pages/Projects.tsx index f7af7ce..38c629c 100644 --- a/studio/web/src/pages/Projects.tsx +++ b/studio/web/src/pages/Projects.tsx @@ -1,19 +1,26 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' -import { api, type BundleImportResult, type ProjectSummary, type Task } from '../api/client' -import PageHeader from '../components/PageHeader' +import { Link, useNavigate } from 'react-router-dom' +import { + api, PHASE_ORDER, + type BundleImportResult, type LoraCkpt, type ProjectSummary, type Task, type VersionPhase, +} from '../api/client' +import KebabMenu from '../components/ds/KebabMenu' +import PageHead from '../components/ds/PageHead' import PathPicker from '../components/PathPicker' import UploadProgressBar from '../components/UploadProgressBar' -import VersionStatusBadge from '../components/VersionStatusBadge' import { useDialog } from '../components/Dialog' import { useToast } from '../components/Toast' +import { splitOptional } from '../lib/labels' import { useEventStream } from '../lib/useEventStream' import { useUploadProgress } from '../lib/useUploadProgress' import { useMonitorProgress } from '../lib/useMonitorProgress' import { buildTrainingForecast, latestProjectFinish } from '../lib/queueEstimates' type ProjectSort = 'queue' | 'finish' | 'updated' | 'created' | 'title' +type ProjectFilter = 'all' | 'active' | 'archived' + +const DAY = 86400 function formatProjectTime(ts: number): string { return new Intl.DateTimeFormat(undefined, { @@ -21,11 +28,50 @@ function formatProjectTime(ts: number): string { }).format(new Date(ts * 1000)) } +function formatClock(ts: number): string { + return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000)) +} + +/** Step key for each version phase (sidebar order, ADR 0010). */ +const PHASE_STEP: Record = { + curating: { key: 'curate', labelKey: 'nav.curate' }, + preprocessing: { key: 'preprocess', labelKey: 'nav.preprocess' }, + editing: { key: 'edit', labelKey: 'nav.tagEdit' }, + regularizing: { key: 'reg', labelKey: 'nav.reg' }, + ready: { key: 'train', labelKey: 'nav.train' }, +} + +const isTrain = (task: Task) => (task.task_type ?? 'train') === 'train' + +/** Seconds a training task spent on the GPU inside [from, now]. */ +function gpuSeconds(task: Task, from: number, now: number): number { + if (!task.started_at) return 0 + const end = task.finished_at ?? (task.status === 'running' ? now : task.paused_at ?? null) + if (end == null) return 0 + return Math.max(0, end - Math.max(task.started_at, from)) +} + +const Icon = { + plus: , + search: , + folder: (s = 15) => , + queue: , + clock: , + hex: (s = 15) => , + arrow: , +} + +interface ProjectExtras { versions: number | null; ckpts: LoraCkpt[] } +interface QueueInfo { order: number; finishesAt: number | null; startsAt: number | null; status: Task['status']; taskId: number } + export default function ProjectsPage() { const { t } = useTranslation() const [items, setItems] = useState([]) const [tasks, setTasks] = useState([]) + const [extras, setExtras] = useState>({}) const [sort, setSort] = useState('queue') + const [filter, setFilter] = useState('all') + const [query, setQuery] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [creating, setCreating] = useState(false) @@ -60,19 +106,48 @@ export default function ProjectsPage() { if (evt.type === 'project_state_changed' || evt.type === 'task_state_changed') void refresh() }) + // Version counts and LoRA checkpoints per project (cards + KPI). Refetched + // only when a project actually changed, not on every queue event. + const extrasKey = items.map((p) => `${p.id}:${p.updated_at}`).join(',') + useEffect(() => { + let cancelled = false + const ids = extrasKey ? extrasKey.split(',').map((s) => Number(s.split(':')[0])) : [] + void Promise.all(ids.map(async (id) => { + const [detail, groups] = await Promise.all([ + api.getProject(id).catch(() => null), + api.listProjectLoraCkpts(id).catch(() => []), + ]) + return [id, { versions: detail ? detail.versions.length : null, ckpts: groups.flatMap((g) => g.items) }] as const + })).then((entries) => { if (!cancelled) setExtras(Object.fromEntries(entries)) }) + return () => { cancelled = true } + }, [extrasKey]) + + const runningTask = tasks.find((task) => task.status === 'running' && isTrain(task)) ?? null const runningTaskId = tasks.find((task) => task.status === 'running')?.id ?? null const { state: monitor } = useMonitorProgress(runningTaskId) const forecast = useMemo(() => buildTrainingForecast(tasks, monitor), [tasks, monitor]) const forecastByProject = useMemo(() => { - const map = new Map() + const map = new Map() forecast.forEach(({ task, finishesAt }, order) => { if (task.project_id != null && !map.has(task.project_id)) { - map.set(task.project_id, { order, finishesAt, status: task.status }) + const startsAt = order === 0 ? (task.started_at ?? null) : forecast[order - 1].finishesAt + map.set(task.project_id, { order, finishesAt, startsAt, status: task.status, taskId: task.id }) } }) return map }, [forecast]) - const sortedItems = useMemo(() => [...items].sort((a, b) => { + + const visibleItems = useMemo(() => { + const q = query.trim().toLowerCase() + return items.filter((p) => { + if (filter === 'active' && p.archived_at != null) return false + if (filter === 'archived' && p.archived_at == null) return false + if (!q) return true + return [p.title, p.slug, p.note ?? ''].some((s) => s.toLowerCase().includes(q)) + }) + }, [items, filter, query]) + + const sortedItems = useMemo(() => [...visibleItems].sort((a, b) => { const aq = forecastByProject.get(a.id) const bq = forecastByProject.get(b.id) if (sort === 'queue') { @@ -100,7 +175,28 @@ export default function ProjectsPage() { if (sort === 'created') return b.created_at - a.created_at if (sort === 'title') return a.title.localeCompare(b.title) return b.updated_at - a.updated_at - }), [items, tasks, forecastByProject, sort]) + }), [visibleItems, tasks, forecastByProject, sort]) + + // ── KPI row (all from real data) ───────────────────────────────────────── + const now = Date.now() / 1000 + const activeProjects = items.filter((p) => p.archived_at == null) + const newThisWeek = activeProjects.filter((p) => p.created_at > now - 7 * DAY).length + const trainQueued = tasks.filter((task) => isTrain(task) && (task.status === 'pending' || task.status === 'scheduled')).length + const trainRunning = tasks.filter((task) => isTrain(task) && task.status === 'running').length + const gpuHours = (days: number) => + tasks.filter(isTrain).reduce((sum, task) => sum + gpuSeconds(task, now - days * DAY, now), 0) / 3600 + const activeIds = new Set(activeProjects.map((p) => p.id)) + const allCkpts = Object.entries(extras).filter(([id]) => activeIds.has(Number(id))).flatMap(([, e]) => e.ckpts) + const ckptsThisWeek = allCkpts.filter((c) => c.mtime > now - 7 * DAY).length + + // ── live tasks for "what is happening right now" ────────────────────────── + const liveTasks = useMemo(() => { + const order = new Map(forecast.map(({ task }, i) => [task.id, i])) + const rank = (task: Task) => (task.status === 'running' ? 0 : task.status === 'pending' ? 1 : task.status === 'scheduled' ? 2 : 3) + return tasks + .filter((task) => task.status === 'running' || task.status === 'pending' || task.status === 'scheduled' || task.status === 'paused') + .sort((a, b) => rank(a) - rank(b) || (order.get(a.id) ?? 99) - (order.get(b.id) ?? 99) || a.created_at - b.created_at) + }, [tasks, forecast]) const handleCreate = async (form: NewProjectForm) => { setBusy(true) @@ -120,9 +216,7 @@ export default function ProjectsPage() { } } - const handleDelete = async (p: ProjectSummary, e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + const handleDelete = async (p: ProjectSummary) => { if (!(await confirm( t('projects.deleteConfirm', { title: p.title, slug: p.slug }), { tone: 'danger', okText: t('projects.deleteProject') }, @@ -136,6 +230,22 @@ export default function ProjectsPage() { } } + const handleArchive = async (p: ProjectSummary) => { + const archived = p.archived_at != null + if (!(await confirm( + archived ? t('projects.unarchiveConfirm', { title: p.title }) : t('projects.archiveConfirm', { title: p.title }), + { okText: archived ? t('projects.unarchiveTitle') : t('projects.archiveBtn') }, + ))) return + try { + if (archived) await api.unarchiveProject(p.id) + else await api.archiveProject(p.id) + toast(archived ? t('projects.unarchived', { title: p.title }) : t('projects.archived', { title: p.title }), 'success') + await refresh() + } catch (err) { + toast(String(err), 'error') + } + } + const finishBundleImport = (result: BundleImportResult) => { const stats = result.stats toast( @@ -185,88 +295,182 @@ export default function ProjectsPage() { } } - const openProject = (p: ProjectSummary) => { - navigate(`/projects/${p.id}`) - } + const projectsById = new Map(items.map((p) => [p.id, p])) + const trainPct = runningTask && monitor?.step != null && monitor.total_steps + ? Math.round((monitor.step / monitor.total_steps) * 100) : null return (
- - } /> -
- {error && ( -
{error}
- )} +
+ {error &&
{error}
} - {!loading && items.length > 0 && ( -
- - +
+ + {Icon.search} + setQuery(e.target.value)} + placeholder={t('projects.searchPlaceholder')} + aria-label={t('projects.searchPlaceholder')} + /> + + +
+ {(['all', 'active', 'archived'] as const).map((f) => ( + + ))}
- )} +
+ +
+
+
+ {Icon.folder()} + {newThisWeek > 0 && {t('projects.kpiWeek', { n: newThisWeek })}} +
+
{activeProjects.length}
+
{t('projects.kpiActive')}
+
+
+
+ {Icon.queue} + {t('projects.kpiRunning', { n: trainRunning })} +
+
{trainQueued}{t('projects.kpiInQueue')}
+
{t('projects.kpiTrainTasks')}
+
+
+
+ {Icon.clock} + {t('projects.kpiGpuWeek', { h: gpuHours(7).toFixed(1) })} +
+
{gpuHours(30).toFixed(1)}{t('projects.kpiHours')}
+
{t('projects.kpiGpu')}
+
+
+
+ {Icon.hex()} + {ckptsThisWeek > 0 && +{ckptsThisWeek}} +
+
{allCkpts.length}
+
{t('projects.kpiCkpts')}
+
+
{loading ? ( -
- {[1, 2, 3].map(i => ( -
-
-
+
+ {[1, 2, 3].map((i) => ( +
+
+
))}
) : items.length === 0 ? ( -
-
{t('projects.noProjects')}
-
{t('projects.noProjectsHint')}
+
+ {t('projects.noProjects')} + {t('projects.noProjectsHint')}
- ) : ( -
+ ) : sortedItems.length > 0 ? ( +
{sortedItems.map((p) => ( openProject(p)} - onDelete={(e) => handleDelete(p, e)} + extras={extras[p.id]} queueInfo={forecastByProject.get(p.id)} + progress={runningTask?.project_id === p.id && monitor?.step != null && monitor.total_steps + ? { step: monitor.step, total: monitor.total_steps, pct: trainPct ?? 0 } : null} + finishedAt={latestProjectFinish(tasks, p.id)} + onArchive={() => void handleArchive(p)} + onDelete={() => void handleDelete(p)} /> ))}
+ ) : ( +
{t('projects.noMatches')}
)} + +
+
+
+
{t('projects.nowTitle')}
+
{t('projects.nowSub')}
+
+
+ {t('projects.nowAll')}{Icon.arrow} +
+
+ {liveTasks.length === 0 ? ( +
+ {t('projects.nowEmpty')} +
+ ) : ( + + + + + + + + {liveTasks.map((task) => ( + navigate(`/queue/${task.id}`)} + /> + ))} + +
{t('projects.colTask')}{t('projects.colProject')}{t('projects.colStatus')}{t('projects.colProgress')}{t('projects.colLeft')} +
+ )} +
{creating && ( @@ -304,6 +508,71 @@ export default function ProjectsPage() { ) } +/** One row of "what is happening right now": task, project, status, progress, time left. */ +function LiveTaskRow({ task, project, forecast, runningPct, onOpen }: { + task: Task + project: ProjectSummary | null + forecast: ReturnType + runningPct: number | null + onOpen: () => void +}) { + const { t } = useTranslation() + const now = Date.now() / 1000 + const kind = task.task_type ?? 'train' + const kindKey = kind.startsWith('eval_') ? 'eval' : kind + const idx = forecast.findIndex((f) => f.task.id === task.id) + const entry = idx >= 0 ? forecast[idx] : null + const startsAt = idx > 0 ? forecast[idx - 1].finishesAt : null + + const badge = task.status === 'running' + ? {t('status.running')} + : task.status === 'paused' + ? {t('status.paused')} + : {t(task.status === 'scheduled' ? 'status.scheduled' : 'status.pending')} + + let progress: React.ReactNode = — + if (task.status === 'running' && runningPct != null) { + progress = ( + + + {runningPct}% + + ) + } else if (task.status === 'pending' && idx > 0) { + progress = {t('projects.ahead', { count: idx })} + } else if (task.status === 'paused' && task.paused_step != null) { + progress = {t('projects.pausedAt', { step: task.paused_step.toLocaleString() })} + } + + let left = '—' + if (task.status === 'running' && entry?.finishesAt) { + const mins = Math.max(1, Math.round((entry.finishesAt - now) / 60)) + left = mins < 60 ? t('projects.leftMin', { m: mins }) : t('projects.leftHours', { h: Math.floor(mins / 60), m: mins % 60 }) + } else if ((task.status === 'pending') && startsAt) { + left = t('projects.startAt', { time: formatClock(startsAt) }) + } else if (task.status === 'scheduled' && task.scheduled_at) { + left = t('projects.startAt', { time: formatProjectTime(task.scheduled_at) }) + } + + return ( + + + + #{task.id} + {t(`projects.taskKind.${kindKey}`, { defaultValue: kind })}{task.config_name || task.name} + + + {project?.title ?? '—'} + {badge} + {progress} + {left} + + + + + ) +} + function BundleImportDialog({ importing, uploadState, @@ -373,78 +642,116 @@ function BundleImportDialog({ ) } -function ProjectCard({ - project: p, - onClick, - onDelete, - queueInfo, -}: { + +/** Project card from the mockup: tile + name + slug·version + ⋯, status + * badges, the 5-step progress bar with a caption, and the continue action. */ +function ProjectCard({ project: p, extras, queueInfo, progress, finishedAt, onArchive, onDelete }: { project: ProjectSummary - onClick: () => void - onDelete: (e: React.MouseEvent) => void - queueInfo?: { order: number; finishesAt: number | null; status: Task['status'] } + extras?: ProjectExtras + queueInfo?: QueueInfo + progress: { step: number; total: number; pct: number } | null + finishedAt: number | null + onArchive: () => void + onDelete: () => void }) { const { t } = useTranslation() + const status = p.active_version_status + // The phase cursor only means something while the version is being + // prepared; once it trains (or trained) it sits on step 5. + const phase: VersionPhase = status === 'preparing' || status == null ? (p.active_version_phase ?? 'curating') : 'ready' + const phaseIdx = Math.max(0, PHASE_ORDER.indexOf(phase)) + const running = queueInfo?.status === 'running' + const queued = queueInfo != null && !running + const trainingNow = running || status === 'training' + const stepLabel = (ph: VersionPhase) => splitOptional(t(PHASE_STEP[ph].labelKey)).short.toLowerCase() + + // 5 segments: finished phases full, the training step follows live progress. + const segments = [0, 1, 2, 3, 4].map((i) => { + if (status === 'completed') return 100 + if (trainingNow || queued) return i < 4 ? 100 : (i === 4 && progress ? progress.pct : 0) + return i < phaseIdx ? 100 : 0 // preparing: finished phases; after preparing: 4 of 5 + }) + + let caption = '' + let captionRight = '' + if (running) { + caption = t('projects.stepOf', { n: 5, step: stepLabel('ready') }) + captionRight = progress ? `${progress.step.toLocaleString()} / ${progress.total.toLocaleString()}` : '' + } else if (queued) { + caption = t('projects.stepOf', { n: 5, step: t('projects.waitingSlot') }) + captionRight = queueInfo?.startsAt ? t('projects.startAt', { time: formatClock(queueInfo.startsAt) }) : '' + } else if (status === 'completed') { + caption = finishedAt ? t('projects.doneAt', { when: formatProjectTime(finishedAt) }) : t('versionStatus.completed') + captionRight = extras ? t('projects.ckpts', { count: extras.ckpts.length }) : '' + } else if (p.active_version_id != null) { + caption = t('projects.stepOf', { n: phaseIdx + 1, step: stepLabel(phase) }) + } + + const badge = running + ? {t('projects.trainingNow')} + : queued + ? {t('projects.queuePosition', { position: (queueInfo?.order ?? 0) + 1 })} + : status === 'completed' + ? {t('projects.modelReady')} + : status === 'failed' + ? {t('versionStatus.failed')} + : status + ? {t(`versionStatus.${status}`)} + : null + + const vid = p.active_version_id + const action = running || (vid != null && phase === 'ready' && status !== 'completed' && !queued) + ? { to: `/projects/${p.id}/v/${vid}/train`, label: t('projects.continueStep', { step: stepLabel('ready') }) } + : queued + ? { to: '/queue', label: t('projects.continueQueue') } + : status === 'completed' + ? { to: '/tools/generate', label: t('projects.viewResult') } + : vid != null + ? { to: `/projects/${p.id}/v/${vid}/${PHASE_STEP[phase].key}`, label: t('projects.continueStep', { step: stepLabel(phase) }) } + : { to: `/projects/${p.id}`, label: t('projects.openProject') } + + const tileStyle: React.CSSProperties = { width: 34, height: 34, borderRadius: 10 } + if (trainingNow) Object.assign(tileStyle, { background: 'var(--green-soft)', color: 'var(--green-text)' }) return ( - + + {action.label} +
) } From 4b24416799eda128d85df43a3f2c0192b9a8ddf4 Mon Sep 17 00:00:00 2001 From: dualchimerra Date: Thu, 24 Sep 2026 21:13:37 +0200 Subject: [PATCH 04/20] design(overview): project overview as in the mockup Page head (edit project / new version / continue), the 7-stage pipeline card with live training progress, four KPIs, a Tasks / LoRA files table and a right column with versions, set composition and top tags, all from real project data. The status-banner actions moved to each task's menu (pause, cancel, log, monitor); the full output folder stays reachable from the file menu. The kebab menu width is set inline so the mockup's card reset can't collapse it. New strings are ru/en only. Co-Authored-By: Claude Opus 5.5 --- studio/web/src/components/ds/KebabMenu.tsx | 6 +- studio/web/src/i18n/locales/en.json | 78 +- studio/web/src/i18n/locales/ru.json | 80 +- .../web/src/pages/project/Overview.test.tsx | 52 +- studio/web/src/pages/project/Overview.tsx | 2130 ++++++++--------- studio/web/src/styles/ds.css | 7 + 6 files changed, 1196 insertions(+), 1157 deletions(-) diff --git a/studio/web/src/components/ds/KebabMenu.tsx b/studio/web/src/components/ds/KebabMenu.tsx index 2aa95b0..b4dbfe7 100644 --- a/studio/web/src/components/ds/KebabMenu.tsx +++ b/studio/web/src/components/ds/KebabMenu.tsx @@ -54,7 +54,9 @@ export default function KebabMenu({ label, items, className = '' }: { {open && (
{items.map((it) => ( diff --git a/studio/web/src/i18n/locales/en.json b/studio/web/src/i18n/locales/en.json index e6e2def..dc9393b 100644 --- a/studio/web/src/i18n/locales/en.json +++ b/studio/web/src/i18n/locales/en.json @@ -2082,7 +2082,83 @@ "openTrain": "Open train" }, "recentRunsCaption": "Recent runs", - "noRuns": "No runs yet." + "noRuns": "No runs yet.", + "eyebrow": "PROJECT · {{slug}}", + "downloads_one": "{{count}} download", + "downloads_other": "{{count}} downloads", + "createdOn": "created {{date}}", + "activeLabel": "active", + "editProject": "Edit project", + "pipelineSub": "Stages of version {{label}} · cursor at step {{n}}", + "noVersion": "The project has no versions yet", + "cellDone": "done", + "cellCurrent": "current step", + "cellSkipped": "skipped", + "cellStep": "step {{step}} / {{total}}", + "cellQueued": "queued", + "cellFailed": "failed", + "cellCanceled": "canceled", + "kpiTrain": "Images in the train set", + "kpiDelta": "{{n}} vs {{label}}", + "kpiCaptioned": "Captioned", + "kpiCaptionedUnique": "Captioned · {{n}} unique tags", + "kpiAllCaptioned": "all captions in place", + "kpiMissingCaptions": "uncaptioned: {{n}}", + "kpiVersionsTotal": "{{v}} in total", + "kpiActiveVersion": "Active version · {{state}}", + "kpiShownVersion": "Selected version · {{state}}", + "kpiPhase": "phase “{{phase}}”", + "kpiCkpts": "Checkpoints in output/", + "kpiLastCkpt": "latest — {{label}}", + "kpiNoCkpts": "none yet", + "ckptEpoch": "epoch {{n}}", + "ckptStep": "step {{n}}", + "ckptFinal": "final", + "ckptResult": "version result", + "colVersion": "Version", + "colStarted": "Started", + "colDuration": "Duration", + "colReady": "Ready", + "tasksNone": "The project has no tasks yet", + "cancelTask": "Cancel task", + "cancelConfirm": "Cancel task #{{id}}?", + "today": "today", + "yesterday": "yesterday", + "durLess": "<1 min", + "durMin": "{{m}} min", + "durHours": "{{h}} h {{m}} m", + "downloadAllZip": "Download all as zip", + "openFolder": "Open folder", + "render": "Render", + "copyPath": "Copy path", + "allOutputs": "All output files", + "fileActions": "File actions", + "openVersion": "Open version", + "verTraining": "Training", + "verTrained": "Trained", + "verPaused": "Paused", + "verPreparing": "Preparing · {{phase}}", + "verTrain": "{{n}} imgs in train", + "verReg": "{{n}} in reg", + "verNoReg": "no reg", + "verCkpt": "checkpoint ready", + "compTitle": "Set composition", + "compSub": "{{total}} imgs · {{train}} in train, {{reg}} in reg", + "compInTrain": "in train", + "compOther": "other", + "compReg": "reg set", + "compResBuckets": "Resolutions · buckets", + "compEmpty": "The train set is empty — pick images in the Curate step.", + "topTags": "Top tags", + "topTagsSub": "{{tagged}} / {{total}} tagged · {{unique}} unique", + "topTagsEmpty": "No captions yet.", + "fieldTitle": "Name", + "fieldNote": "Note", + "saved": "Project saved", + "unitB": "B", + "unitKB": "KB", + "unitMB": "MB", + "unitGB": "GB" }, "versionStatus": { "preparing": "Preparing", diff --git a/studio/web/src/i18n/locales/ru.json b/studio/web/src/i18n/locales/ru.json index 680aebb..fb6eea1 100644 --- a/studio/web/src/i18n/locales/ru.json +++ b/studio/web/src/i18n/locales/ru.json @@ -724,7 +724,85 @@ "openTrain": "Открыть тренировку" }, "recentRunsCaption": "Последние прогоны", - "noRuns": "Прогонов пока нет." + "noRuns": "Прогонов пока нет.", + "eyebrow": "ПРОЕКТ · {{slug}}", + "downloads_one": "{{count}} загрузка", + "downloads_few": "{{count}} загрузки", + "downloads_many": "{{count}} загрузок", + "downloads_other": "{{count}} загрузки", + "createdOn": "создан {{date}}", + "activeLabel": "активная", + "editProject": "Изменить проект", + "pipelineSub": "Этапы версии {{label}} · курсор на шаге {{n}}", + "noVersion": "У проекта пока нет версий", + "cellDone": "готово", + "cellCurrent": "текущий шаг", + "cellSkipped": "пропущен", + "cellStep": "шаг {{step}} / {{total}}", + "cellQueued": "в очереди", + "cellFailed": "ошибка", + "cellCanceled": "отменено", + "kpiTrain": "Изображений в train-наборе", + "kpiDelta": "{{n}} к {{label}}", + "kpiCaptioned": "С капшенами", + "kpiCaptionedUnique": "С капшенами · {{n}} уникальных тегов", + "kpiAllCaptioned": "все капшены на месте", + "kpiMissingCaptions": "без капшенов: {{n}}", + "kpiVersionsTotal": "всего {{v}}", + "kpiActiveVersion": "Активная версия · {{state}}", + "kpiShownVersion": "Выбранная версия · {{state}}", + "kpiPhase": "фаза «{{phase}}»", + "kpiCkpts": "Чекпоинтов в output/", + "kpiLastCkpt": "последний — {{label}}", + "kpiNoCkpts": "пока нет", + "ckptEpoch": "эпоха {{n}}", + "ckptStep": "шаг {{n}}", + "ckptFinal": "итоговый", + "ckptResult": "результат версии", + "colVersion": "Версия", + "colStarted": "Начало", + "colDuration": "Длительность", + "colReady": "Готов", + "tasksNone": "У проекта пока нет задач", + "cancelTask": "Отменить задачу", + "cancelConfirm": "Отменить задачу #{{id}}?", + "today": "сегодня", + "yesterday": "вчера", + "durLess": "<1 мин", + "durMin": "{{m}} мин", + "durHours": "{{h}} ч {{m}} м", + "downloadAllZip": "Скачать всё zip", + "openFolder": "Открыть папку", + "render": "Рендер", + "copyPath": "Копировать путь", + "allOutputs": "Все файлы вывода", + "fileActions": "Действия с файлом", + "openVersion": "Открыть версию", + "verTraining": "Тренируется", + "verTrained": "Натренирована", + "verPaused": "На паузе", + "verPreparing": "Подготовка · {{phase}}", + "verTrain": "{{n}} изобр. в train", + "verReg": "{{n}} в reg", + "verNoReg": "без reg", + "verCkpt": "чекпоинт готов", + "compTitle": "Состав набора", + "compSub": "{{total}} изобр. · {{train}} в train, {{reg}} в reg", + "compInTrain": "в train", + "compOther": "прочие", + "compReg": "reg-набор", + "compResBuckets": "Разрешения · бакеты", + "compEmpty": "Train-набор пока пуст — отберите изображения на шаге «Отбор».", + "topTags": "Топ тегов", + "topTagsSub": "{{tagged}} / {{total}} с тегами · {{unique}} уникальных", + "topTagsEmpty": "Капшенов пока нет.", + "fieldTitle": "Название", + "fieldNote": "Заметка", + "saved": "Проект сохранён", + "unitB": "Б", + "unitKB": "КБ", + "unitMB": "МБ", + "unitGB": "ГБ" }, "schema": { "enums": { diff --git a/studio/web/src/pages/project/Overview.test.tsx b/studio/web/src/pages/project/Overview.test.tsx index 2eb4bfb..b357188 100644 --- a/studio/web/src/pages/project/Overview.test.tsx +++ b/studio/web/src/pages/project/Overview.test.tsx @@ -1,16 +1,19 @@ -/** ProjectOverview 组件级 regression test。 +/** ProjectOverview regression test. * - * 覆盖训练态 StatusBanner 的暂停按钮实时刷新:latestTask 是 Overview 内独立 - * local state(不是 project prop),Layout 的 version_state_changed reload 碰 - * 不到它。暂停按钮门控的 is_pausable 由 train_loop_started + 首个 epoch 的 - * auto_epoch_backup_written 翻 true —— Overview 必须自己订阅这些 SSE 事件重拉 - * latestTask,否则暂停按钮训练期一直不出现,得切版本 / 刷新页面才有。 + * The pause action of a running training task must appear live: the task + * list is Overview's own local state (not a project prop), so Layout's + * version_state_changed reload never touches it. is_pausable flips to true + * on train_loop_started + the first epoch's auto_epoch_backup_written, so + * Overview has to subscribe to those SSE events and refetch the queue; + * otherwise the pause item never shows up until a version switch or reload. * - * jsdom 默认没有 EventSource(useEventStream 内部 typeof 守卫会短路),这里塞 - * 个 fake 让 hook 真订阅,再手动驱动事件验证组件会重拉 listQueue 并显示按钮。 */ -import { render, screen, waitFor } from '@testing-library/react' + * jsdom has no EventSource (useEventStream's typeof guard short-circuits), so + * a fake is installed to make the hook subscribe, then events are driven by + * hand to check that the component refetches listQueue and shows the item. */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DialogProvider } from '../../components/Dialog' import { ToastProvider } from '../../components/Toast' import { api, type ProjectDetail, type Task, type Version } from '../../api/client' import ProjectOverview from './Overview' @@ -68,11 +71,13 @@ function renderOverview(project: ProjectDetail) { return render( - - }> - } /> - - + + + }> + } /> + + + ) @@ -81,8 +86,8 @@ function renderOverview(project: ProjectDetail) { beforeEach(() => { FakeEventSource.instances = [] vi.stubGlobal('EventSource', FakeEventSource) - // detail tab 的 getCuration / listCaptionsFull 等都有 .catch —— 统一 404 让它们 - // 安静失败,不污染测试;listQueue 单独 spy 控制返回。 + // Captions / outputs / checkpoints all have a .catch: a blanket 404 lets them + // fail quietly; listQueue is spied separately to control what it returns. vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, status: 404, json: async () => null, text: async () => '', headers: new Headers(), @@ -94,27 +99,28 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('ProjectOverview 训练态暂停按钮 SSE 刷新', () => { - it('auto_epoch_backup_written 事件触发重拉 latestTask → 暂停按钮出现', async () => { - // listQueue:首拉 is_pausable=false(首个 epoch backup 未落盘),之后 true。 +describe('ProjectOverview pause action refreshes over SSE', () => { + it('auto_epoch_backup_written refetches the tasks and the pause item appears', async () => { + // listQueue: is_pausable=false at first (no epoch backup on disk yet), true afterwards. let pausable = false const listSpy = vi.spyOn(api, 'listQueue') .mockImplementation(async () => [makeTrainTask(pausable)]) renderOverview(makeProject()) - // 训练 banner 已渲染(取消训练按钮随 taskId 出现),但 is_pausable=false → - // 暂停按钮不在。 + // Open the task's ⋯ menu: cancel is there, pause is not (is_pausable=false). + const kebab = await screen.findByRole('button', { name: 'Actions' }) + fireEvent.click(kebab) await waitFor(() => expect(screen.getByText('取消训练')).toBeInTheDocument()) expect(screen.queryByText('暂停')).not.toBeInTheDocument() const callsBefore = listSpy.mock.calls.length - // 后端首个 epoch backup 落盘 → is_pausable 升级;推一条 SSE 事件。 + // The first epoch backup lands on disk -> is_pausable flips; push an SSE event. pausable = true await waitFor(() => expect(FakeEventSource.instances.length).toBeGreaterThan(0)) FakeEventSource.instances[0].emit({ type: 'auto_epoch_backup_written', task_id: 42 }) - // 重拉后暂停按钮出现(不依赖切版本 / 刷新页面)。 + // After the refetch the open menu shows pause (no version switch / reload needed). await waitFor(() => expect(screen.getByText('暂停')).toBeInTheDocument()) expect(listSpy.mock.calls.length).toBeGreaterThan(callsBefore) }) diff --git a/studio/web/src/pages/project/Overview.tsx b/studio/web/src/pages/project/Overview.tsx index 2348560..f1c06c6 100644 --- a/studio/web/src/pages/project/Overview.tsx +++ b/studio/web/src/pages/project/Overview.tsx @@ -1,41 +1,35 @@ -/** 项目详情页 — Design v2 实装(pvt-detail-v2.jsx) +/** Project overview, laid out as the approved mockup (Overview / OverviewFiles + * artboards): page head with the project actions, the pipeline card, four + * KPIs, a Tasks / LoRA files table and a right column with versions, the + * train-set composition and the top tags. Every number comes from the API; + * a block with nothing to show says so instead of inventing values. * - * 顶部:Identity strip(glyph + title/version/status + meta caption) - * → 横向 VersionRail(pill 行) - * → 5 状态 StatusBanner(preparing / training / completed / failed / canceled) - * → Tabs (详情 / Tasks / Output) - * → 详情 = 2+3 不对称 grid(训练集 hero + 标签分布 hero / 分辨率 + 长宽比 + 正则集) - * - * TopBar (面包屑 + sys stats) 不实装 —— 已被 sidebar/全局区覆盖。 - * Live 训练进度 (step/total/ETA) 不实装 —— 需 SSE/monitor state 整合,留 follow-up。 - * "复制配置开新版本" / "调小 batch 重训" 需新后端 API,渲染为占位按钮 toast 提示。 - */ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' + * The page shows one version at a time: `?version=N` (links from the queue) + * or the active version. Task actions that used to live in the status + * banner (pause, cancel, log, monitor) sit in each task's ⋯ menu; the full + * output folder (training states, delete, export) stays one click away in + * the task's Outputs tab. */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate, useOutletContext } from 'react-router-dom' +import { Link, useNavigate, useOutletContext } from 'react-router-dom' import { api, - type CurationView, + PHASE_ORDER, + type LoraCkpt, type ProjectDetail, type Task, type TaskOutputs, type Version, type VersionPhase, } from '../../api/client' -import PageHeader from '../../components/PageHeader' -import VersionStatusBadge from '../../components/VersionStatusBadge' -import BarHistogram from '../../components/BarHistogram' -import { TranslatedTag } from '../../components/tagDisplay/TranslatedTag' -import ImageGrid, { type ImageGridItem } from '../../components/ImageGrid' -import ImagePreviewModal from '../../components/ImagePreviewModal' -import { OutputsTab } from '../QueueDetail' -import { arBucket } from '../../lib/aspectRatio' -import { computePixelHist } from '../../lib/pixelBins' -import { useProjectCtx } from '../../context/ProjectContext' +import KebabMenu, { type KebabItem } from '../../components/ds/KebabMenu' +import PageHead from '../../components/ds/PageHead' +import { useDialog } from '../../components/Dialog' import { useToast } from '../../components/Toast' +import { useProjectCtx } from '../../context/ProjectContext' +import { splitOptional } from '../../lib/labels' import { useEventStream } from '../../lib/useEventStream' - -type OverviewTab = 'details' | 'tasks' | 'output' +import { useMonitorProgress } from '../../lib/useMonitorProgress' interface Ctx { project: ProjectDetail @@ -45,1228 +39,1104 @@ interface Ctx { creatingVersionBusy: boolean } -// ── helpers ────────────────────────────────────────────────────────────── +type OverviewTab = 'tasks' | 'files' -function fmtAgo(ts: number | null | undefined): string { - if (!ts) return '—' - const sec = Math.max(0, Date.now() / 1000 - ts) - if (sec < 60) return 'just now' - if (sec < 3600) return `${Math.floor(sec / 60)}m ago` - if (sec < 86400) return `${Math.floor(sec / 3600)}h ago` - return `${Math.floor(sec / 86400)}d ago` -} +// ── helpers ────────────────────────────────────────────────────────────── -// ── PipelineStrip(redesign 原型:圆形序号 + 连接线 + 标签在下) ────────── +const DAY = 86400 -function PipelineStrip({ project, version }: { project: ProjectDetail; version: Version | null }) { - const { t } = useTranslation() - const navigate = useNavigate() - const isPreparing = version?.status === 'preparing' - const ci = version ? PHASE_ORDER_TIMELINE.findIndex((p) => p.id === version.phase) : -1 - const steps: Array<{ key: string; label: string; n: string | null; phaseId: VersionPhase | null }> = [ - { key: 'download', label: t('nav.download'), n: null, phaseId: null }, - ...PHASE_ORDER_TIMELINE.map((p) => ({ - key: PHASE_TO_STEP_LOCAL[p.id], label: t(p.key), n: p.n, phaseId: p.id as VersionPhase | null, - })), - ] - const continueTarget = isPreparing && version - ? PHASE_ORDER_TIMELINE.find((p) => p.id === version.phase) ?? null - : null - return ( -
-
- Pipeline{version ? ` · ${version.label}` : ''} -
-
- {steps.map((s, idx) => { - const isProject = s.phaseId === null - const pi = s.phaseId ? PHASE_ORDER_TIMELINE.findIndex((p) => p.id === s.phaseId) : -1 - const done = !isProject && isPreparing && pi < ci - const current = !isProject && isPreparing && pi === ci - const allowed = isProject - ? true - : version != null && canGoVersionPhase(version, s.phaseId!) - const to = isProject - ? `/projects/${project.id}/download` - : version ? `/projects/${project.id}/v/${version.id}/${s.key}` : null - return ( -
- {idx > 0 && ( -
- )} - -
- ) - })} -
- {isPreparing && version && ( -
-
-
{t('overview.banner.metaCurrentPhase')}
-
- {t(continueTarget?.key ?? 'nav.curate')} -
-
- {version.stats && ( -
-
{t('overview.banner.metaTagged')}
-
- {version.stats.tagged_image_count} / {version.stats.train_image_count} -
-
- )} - - {continueTarget && ( - - )} -
- )} -
- ) +/** Route segment and sidebar number of each version phase. */ +const PHASE_STEP: Record = { + curating: { key: 'curate', n: 1, labelKey: 'nav.curate' }, + preprocessing: { key: 'preprocess', n: 2, labelKey: 'nav.preprocess' }, + editing: { key: 'edit', n: 3, labelKey: 'nav.tagEdit' }, + regularizing: { key: 'reg', n: 4, labelKey: 'nav.reg' }, + ready: { key: 'train', n: 5, labelKey: 'nav.train' }, } -// ── VersionRowCard(redesign 原型:版本列表行卡) ───────────────────────── +const phaseIndex = (phase: VersionPhase) => PHASE_ORDER.indexOf(phase) -function VersionRowCard({ - project, v, selected, onView, -}: { - project: ProjectDetail - v: Version - selected: boolean - onView: (vid: number) => void -}) { - const { t } = useTranslation() - const navigate = useNavigate() - const ctx = useProjectCtx() - const isActive = v.id === project.active_version_id - const meta: Array<[string, string]> = [ - ['train', v.stats ? `${v.stats.train_image_count} imgs` : '—'], - ['reg', v.stats ? `${v.stats.reg_image_count} imgs` : '—'], - ['phase', v.status === 'preparing' ? t(PHASE_ORDER_TIMELINE.find((p) => p.id === v.phase)?.key ?? 'nav.curate') : t(`versionStatus.${v.status}`)], - ['created', fmtAgo(v.created_at)], - ] - return ( -
onView(v.id)} - style={{ - padding: 18, cursor: 'pointer', - borderColor: selected ? 'var(--accent-veil)' : 'var(--border-subtle)', - boxShadow: selected ? '0 0 0 1px var(--accent-veil)' : 'none', - }} - > -
- {v.label} - - {isActive && active} - - - {!isActive && ( - - )} -
- {v.note &&

{v.note}

} -
- {meta.map(([k, val]) => ( -
-
{k}
-
{val}
-
- ))} -
-
- ) +/** While a version is being prepared only the cursor phase and the ones + * before it are reachable (advancing goes through each step's own + * "continue" check). Trained / training versions can revisit any step. */ +function canGoPhase(version: Version | null, phase: VersionPhase): boolean { + if (!version) return false + if (version.status !== 'preparing') return true + return phaseIndex(phase) <= phaseIndex(version.phase) } -// ── StatusBanner shared bits ───────────────────────────────────────────── +function startOfDay(ts: number): number { + const d = new Date(ts * 1000) + d.setHours(0, 0, 0, 0) + return d.getTime() / 1000 +} -const bannerMetaRow: React.CSSProperties = { - display: 'flex', gap: 18, flexWrap: 'wrap', - paddingTop: 10, marginTop: 4, - borderTop: '1px solid var(--border-subtle)', +function fmtClock(ts: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000)) } -const bannerActions: React.CSSProperties = { - display: 'flex', gap: 6, marginTop: 12, marginLeft: 'auto', - justifyContent: 'flex-end', flexWrap: 'wrap', + +function fmtDate(ts: number, locale: string, withYear = false): string { + return new Intl.DateTimeFormat(locale, withYear + ? { day: 'numeric', month: 'short', year: 'numeric' } + : { day: 'numeric', month: 'short' }).format(new Date(ts * 1000)) } -function BannerMeta({ k, v }: { k: string; v: string | number }) { - return ( -
- {k} - {v} -
- ) +function fmtBytes(t: (k: string) => string, n: number): string { + if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} ${t('overview.unitGB')}` + if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} ${t('overview.unitMB')}` + if (n >= 1024) return `${Math.round(n / 1024)} ${t('overview.unitKB')}` + return `${n} ${t('overview.unitB')}` } -function BannerShell({ - tint, iconChar, iconColor, iconPulse, title, sub, children, -}: { - tint: 'err' | 'warn' | 'accent' | 'ok' - iconChar: string - iconColor: string - iconPulse?: boolean - title: string - sub?: string - children: ReactNode -}) { - // Tailark card:白底卡片 + 顶部状态色细条,状态色只落在图标 tile / 顶条上。 - const tintMap = { - err: { bg: 'var(--err-soft)', border: 'var(--err-line)' }, - warn: { bg: 'var(--warn-soft)', border: 'var(--warn-line)' }, - accent: { bg: 'var(--accent-soft)', border: 'var(--accent-line)' }, - ok: { bg: 'var(--ok-soft)', border: 'var(--ok-line)' }, - } - const tCfg = tintMap[tint] - return ( -
-
- {iconChar} -
-
{title}
- {sub &&
{sub}
} -
-
-
- {children} -
-
- ) +const normPath = (p: string) => p.replace(/\\/g, '/').toLowerCase() + +const isTrainTask = (task: Task) => (task.task_type ?? 'train') === 'train' + +const Icon = { + arrow: , + rowgo: , + check: , + image: , + tag: , + branch: , + hex: (s: number) => 13 ? 1.7 : 1.8} strokeLinecap="round" strokeLinejoin="round">, } -function BannerProgress({ - now, total, running, muted, fail, -}: { now: number; total: number; running?: boolean; muted?: boolean; fail?: boolean }) { - const pct = total > 0 ? Math.min(100, (now / total) * 100) : 0 - const color = fail ? 'var(--err)' : muted ? 'var(--fg-disabled)' : 'var(--accent)' - return ( -
-
- {muted && ( -
- )} -
- ) +/** One LoRA file row: from the latest training task's output listing when + * there is one (has size, download, zip), else from the version's + * checkpoint scan. */ +interface LoraRow { + name: string + /** Absolute path, used for the generate page and "copy path". */ + path: string + /** Path relative to output/, only for task-backed rows. */ + relPath: string | null + size: number | null + mtime: number } -const PHASE_ORDER_TIMELINE: { id: VersionPhase; n: string; key: string }[] = [ - { id: 'curating', n: '1', key: 'nav.curate' }, - { id: 'preprocessing', n: '2', key: 'nav.preprocess' }, - { id: 'editing', n: '3', key: 'nav.tagEdit' }, - { id: 'regularizing', n: '4', key: 'nav.reg' }, - { id: 'ready', n: '5', key: 'nav.train' }, -] - -// ── StatusBanner ───────────────────────────────────────────────────────── - -function StatusBanner({ - projectId, version, latestTask, onOpenOutput, -}: { - projectId: number - version: Version - latestTask: Task | null - /** "下载" CTA 切到下方 [Output] tab */ - onOpenOutput: () => void -}) { - const { t } = useTranslation() +// ── main ───────────────────────────────────────────────────────────────── + +export default function ProjectOverview() { + const { t, i18n } = useTranslation() + const { project, activeVersion, reload } = useOutletContext() + const ctx = useProjectCtx() const navigate = useNavigate() const { toast } = useToast() - const ctx = useProjectCtx() - const taskId = latestTask?.id + const { confirm } = useDialog() - // 拉 task outputs —— completed 状态时 version.output_lora_path 可能为空(早期 - // 训练 supervisor 未回填),用 task outputs.files (is_lora) 兜底找产物名。 - const [taskOutputs, setTaskOutputs] = useState(null) + // Shown version: `?version=N` (queue links) first, then the active one. + // The query is dropped right away so a refresh does not pin the version. + const [selectedVid, setSelectedVid] = useState(() => { + try { + const v = new URLSearchParams(window.location.search).get('version') + if (v && Number.isFinite(Number(v))) return Number(v) + } catch { /* ignore */ } + return project.active_version_id ?? activeVersion?.id ?? null + }) useEffect(() => { - if (!taskId || version.status !== 'completed') { setTaskOutputs(null); return } + try { + const url = new URL(window.location.href) + if (url.searchParams.has('version')) { + url.searchParams.delete('version') + window.history.replaceState({}, '', url.toString()) + } + } catch { /* ignore */ } + }, []) + // Follow the active version when it changes (sidebar switch, activation + // from the versions card), but not on mount so `?version=N` survives. + const prevActiveRef = useRef(project.active_version_id) + useEffect(() => { + if (prevActiveRef.current !== project.active_version_id) { + prevActiveRef.current = project.active_version_id + setSelectedVid(project.active_version_id) + } + }, [project.active_version_id]) + useEffect(() => { + if (!project.versions.some((v) => v.id === selectedVid)) setSelectedVid(project.active_version_id ?? null) + }, [project.versions, project.active_version_id, selectedVid]) + + const version: Version | null = project.versions.find((v) => v.id === selectedVid) ?? null + const vid = version?.id ?? null + + // All tasks of the project, newest first. + const [tasks, setTasks] = useState([]) + const [tasksLoaded, setTasksLoaded] = useState(false) + const loadTasks = useCallback(() => { let cancelled = false - void api.getTaskOutputs(taskId) - .then((res) => { if (!cancelled) setTaskOutputs(res) }) - .catch(() => { if (!cancelled) setTaskOutputs(null) }) + void api.listQueue() + .then((items) => { + if (cancelled) return + setTasks(items + .filter((tk) => tk.project_id === project.id) + .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))) + }) + .catch(() => { if (!cancelled) setTasks([]) }) + .finally(() => { if (!cancelled) setTasksLoaded(true) }) return () => { cancelled = true } - }, [taskId, version.status]) + }, [project.id]) + useEffect(() => loadTasks(), [loadTasks]) - const goLog = () => taskId && navigate(`/queue/${taskId}#log`) - const goMonitor = () => taskId && navigate(`/queue/${taskId}#monitor`) + const [outputsKey, setOutputsKey] = useState(0) + useEventStream((evt) => { + if (evt.type === 'train_loop_started' + || evt.type === 'auto_epoch_backup_written' + || evt.type === 'task_state_changed') { + loadTasks() + setOutputsKey((k) => k + 1) + } + }) - const fmtTime = (ts: number | null | undefined) => - ts ? new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false, dateStyle: 'short', timeStyle: 'short' }) : '—' + const versionTasks = useMemo(() => tasks.filter((tk) => tk.version_id === vid), [tasks, vid]) + const latestTrain = useMemo(() => versionTasks.find(isTrainTask) ?? null, [versionTasks]) + const runningTrain = latestTrain?.status === 'running' ? latestTrain : null + const { state: monitor } = useMonitorProgress(runningTrain?.id ?? null) + const trainProgress = runningTrain && monitor?.step != null && monitor.total_steps + ? { step: monitor.step, total: monitor.total_steps } : null + + // LoRA files of the shown version. + const [outputs, setOutputs] = useState(null) + const [ckpts, setCkpts] = useState([]) + const latestTrainId = latestTrain?.id ?? null + useEffect(() => { + let cancelled = false + setOutputs(null) + if (latestTrainId != null) { + void api.getTaskOutputs(latestTrainId) + .then((r) => { if (!cancelled) setOutputs(r) }) + .catch(() => { if (!cancelled) setOutputs(null) }) + } + if (vid != null) { + void api.listVersionLoraCkpts(project.id, vid) + .then((r) => { if (!cancelled) setCkpts(r) }) + .catch(() => { if (!cancelled) setCkpts([]) }) + } else { + setCkpts([]) + } + return () => { cancelled = true } + }, [project.id, vid, latestTrainId, outputsKey]) + + const loraRows = useMemo(() => { + if (outputs?.exists) { + const dir = outputs.output_dir ?? '' + return outputs.files + .filter((f) => f.is_lora) + .map((f) => ({ name: f.name, path: dir ? `${dir}/${f.path}` : f.path, relPath: f.path, size: f.size, mtime: f.mtime })) + .sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name, undefined, { numeric: true })) + } + return ckpts + .map((c) => ({ name: c.path.split(/[\\/]/).pop() ?? c.label, path: c.path, relPath: null, size: null, mtime: c.mtime })) + .sort((a, b) => b.mtime - a.mtime) + }, [outputs, ckpts]) + + // Captions: top tags and the unique-tag count. + const [tagCounts, setTagCounts] = useState | null>(null) + // Train images: resolution buckets. + const [sizes, setSizes] = useState | null>(null) + useEffect(() => { + if (vid == null) { setTagCounts(null); setSizes(null); return } + let cancelled = false + void api.listCaptionsFull(project.id, vid) + .then((res) => { + if (cancelled) return + const counter = new Map() + for (const it of res.items) for (const tg of it.tags) counter.set(tg, (counter.get(tg) ?? 0) + 1) + setTagCounts(Array.from(counter, ([tag, n]) => ({ tag, n })) + .sort((a, b) => b.n - a.n || a.tag.localeCompare(b.tag))) + }) + .catch(() => { if (!cancelled) setTagCounts(null) }) + void api.listPreprocessFilesTrain(project.id, vid) + .then((res) => { + if (cancelled) return + setSizes(res.images.flatMap((i) => (i.w && i.h ? [{ w: i.w, h: i.h }] : []))) + }) + .catch(() => { if (!cancelled) setSizes(null) }) + return () => { cancelled = true } + }, [project.id, vid, version?.stats?.train_image_count, version?.stats?.tagged_image_count]) - if (version.status === 'canceled') { - const cancelReason = latestTask?.error_msg || t('overview.banner.canceledReasonDefault') - return ( - -
- - - {taskId && } - - -
-
- ) - } + const [tab, setTab] = useState('tasks') + const [editing, setEditing] = useState(false) - if (version.status === 'failed') { - const reason = version.last_failure_reason || latestTask?.error_msg || t('overview.banner.failedReasonDefault') - return ( - -
- - - {taskId && } - - -
-
- ) - } + const stepLabel = (phase: VersionPhase) => splitOptional(t(PHASE_STEP[phase].labelKey)).short + const stepRoute = (v: Version, phase: VersionPhase) => `/projects/${project.id}/v/${v.id}/${PHASE_STEP[phase].key}` + const cursorPhase = (v: Version): VersionPhase => (v.status === 'preparing' ? v.phase : 'ready') - if (version.status === 'training') { - const startedAt = latestTask?.started_at - return ( - - -
- - {latestTask?.is_pausable && ( - - )} -
-
- {latestTask?.is_pausable && ( - - )} - {taskId && ( - - )} - {taskId && } -
-
- ) + const generateUrl = (v: Version, loraPath: string) => { + const sp = new URLSearchParams() + sp.set('lora', loraPath) + sp.set('projectId', String(project.id)) + sp.set('versionId', String(v.id)) + return `/tools/generate?${sp.toString()}` } - - if (version.status === 'completed') { - // 测试中加载用完整 path:version 字段优先;空时用 task outputs 第一个 LoRA 文件 兜底 - const loraFromTask = taskOutputs?.files.find((f) => f.is_lora)?.name ?? null - const loraPathForTest = version.output_lora_path - || (taskOutputs?.output_dir && loraFromTask ? `${taskOutputs.output_dir}/${loraFromTask}` : null) - return ( - -
- {taskId && } - {taskOutputs && ( - f.is_lora).length} - /> - )} - - - - -
-
- ) + const resultPath = version?.output_lora_path || loraRows[0]?.path || null + + // Primary action in the page head. + let primary: { to: string; label: string } | null = null + if (version) { + if (version.status === 'completed' && resultPath) { + primary = { to: generateUrl(version, resultPath), label: t('projects.viewResult') } + } else { + const phase = cursorPhase(version) + primary = { to: stepRoute(version, phase), label: t('projects.continueStep', { step: stepLabel(phase).toLowerCase() }) } + } } - // preparing — 状态叙事由 PipelineStrip(圆形步骤条 + Continue CTA)承担, - // banner 只负责 task 态(training / failed / completed / canceled)。 - return null -} - -/** phase enum → URL step key(StatusBanner 内用,独立于 sidebar 的同名 map)。 */ -const PHASE_TO_STEP_LOCAL: Record = { - curating: 'curate', - preprocessing: 'preprocess', - editing: 'edit', - regularizing: 'reg', - ready: 'train', -} - -/** cursor 校验:preparing 态下只允许 cursor 及之前的 phase(cursor+1 也禁, - * 推进必须走 banner 的 "继续 X →" 按钮,那里会调 advance API 校验完成条件)。 - * 非 preparing 态(已训练 / 训练中 / 终态)所有 phase 都允许跳(回看历史)。 */ -function canGoVersionPhase(version: Version | null, phase: VersionPhase): boolean { - if (!version) return false - if (version.status !== 'preparing') return true - const cursorIdx = PHASE_ORDER_TIMELINE.findIndex((p) => p.id === version.phase) - const targetIdx = PHASE_ORDER_TIMELINE.findIndex((p) => p.id === phase) - return targetIdx <= cursorIdx -} - -// ── HeroCard / 详情 card 通用 shell ────────────────────────────────────── - -function HeroCard({ - title, count, countSub, action, phase, children, -}: { - title: string - count?: number | null - countSub?: string - /** disabled 时按钮 opacity 0.4 + cursor not-allowed,点击不触发 onClick(cursor 校验失败用) */ - action?: { label: string; onClick: () => void; disabled?: boolean } - phase?: string - children: ReactNode -}) { - return ( -
-
-

{title}

- - {count != null && ( - - {count} - {countSub && {countSub}} - - )} -
-
{children}
- {action && ( -
- {phase && ( - {phase} - )} - -
- )} -
+ const stats = version?.stats + const train = stats?.train_image_count ?? 0 + const tagged = stats?.tagged_image_count ?? 0 + const reg = stats?.reg_image_count ?? 0 + + const subtitle = ( + <> + {t('overview.downloads', { count: project.download_image_count ?? 0 })} + {' · '}{t('projects.versions', { count: project.versions.length })} + {' · '}{t('overview.createdOn', { date: fmtDate(project.created_at, i18n.language, true) })} + {activeVersion && <>{' · '}{t('overview.activeLabel')} {activeVersion.label}} + {project.note && {project.note}} + ) -} - -// ── TrainSetCard (hero) ────────────────────────────────────────────────── -// 文件夹 chips 放 header 右边(跟 Curation 训练集 panel 同款);body 用 -// ImageGrid 渲染当前 folder 的图,点击放大走 ImagePreviewModal。 - -const EMPTY_SELECTED: Set = new Set() - -function TrainSetCard({ project, version }: { project: ProjectDetail; version: Version | null }) { - const { t } = useTranslation() - const navigate = useNavigate() - const [view, setView] = useState(null) - const [selectedFolder, setSelectedFolder] = useState('all') - const [previewIdx, setPreviewIdx] = useState(null) - - useEffect(() => { - if (!version) { setView(null); return } - let cancelled = false - void api.getCuration(project.id, version.id) - .then((res) => { if (!cancelled) setView(res) }) - .catch(() => { if (!cancelled) setView(null) }) - return () => { cancelled = true } - }, [project.id, version]) - const folders = view?.folders ?? [] - const folderCounts: Record = useMemo(() => { - if (!view) return {} - const out: Record = {} - for (const f of view.folders) out[f] = (view.right[f] ?? []).length - return out - }, [view]) - const total = view?.train_total ?? version?.stats?.train_image_count ?? 0 - - // 当前选中 folder 的图,转换为 ImageGridItem[] - const items = useMemo>(() => { - if (!view || !version) return [] - const out: Array = [] - const list = selectedFolder === 'all' ? view.folders : [selectedFolder] - for (const folder of list) { - const arr = view.right[folder] ?? [] - for (const it of arr) { - out.push({ - name: `${folder}/${it.name}`, - pureName: it.name, - folder, - thumbUrl: api.versionThumbUrl(project.id, version.id, 'train', it.name, folder, 256), - }) - } - } - return out - }, [view, version, project.id, selectedFolder]) + return ( +
+ + + + {primary && {primary.label}{Icon.arrow}} + + } + /> - // 预览大图 src(1600 大小) - const previewItem = previewIdx != null ? items[previewIdx] : null - const previewSrc = previewItem && version - ? api.versionThumbUrl(project.id, version.id, 'train', previewItem.pureName, previewItem.folder, 1600) - : '' +
+ - const actionDisabled = !canGoVersionPhase(version, 'curating') - const phaseLine = `${t('nav.download')} → ${t('nav.preprocess')} → ${t('nav.curate')}` + - return ( -
- {/* Header: title + folder chips on right */} -
-

- {t('overview.detail.folders')} -

- - {folders.length > 0 && ( -
- setSelectedFolder('all')} - /> - {folders.map((f) => ( - setSelectedFolder(f)} +
+
+
+ + +
+ {tab === 'tasks' ? ( + navigate(`/queue/${id}${hash ?? ''}`)} + onPause={(id) => { api.pauseTask(id).then(loadTasks).catch((e) => toast(String(e), 'error')) }} + onCancel={async (task) => { + const ok = await confirm(t('overview.cancelConfirm', { id: task.id }), { tone: 'danger' }) + if (!ok) return + api.cancelTask(task.id).then(loadTasks).catch((e) => toast(String(e), 'error')) + }} + /> + ) : ( + version && navigate(generateUrl(version, row.path))} /> - ))} + )}
- )} -
- - {/* Body: ImageGrid 或 empty */} -
- {!version || items.length === 0 ? ( -

{t('overview.detail.emptyCurate')}

- ) : ( - { /* read-only */ }} - clickMode="activate" - onActivate={(name) => setPreviewIdx(items.findIndex((i) => i.name === name))} - onPreview={(name) => setPreviewIdx(items.findIndex((i) => i.name === name))} - ariaLabel="overview-train-grid" - /> - )} -
- {/* Action row */} - {version && ( -
- {phaseLine} - +
+ { + const isActive = v.id === project.active_version_id + if (!isActive && v.status === 'completed' && v.output_lora_path) { + navigate(generateUrl(v, v.output_lora_path)) + return + } + if (!isActive) ctx?.onSelectVersion(v.id) + navigate(stepRoute(v, cursorPhase(v))) + }} + /> + + +
- )} +
- {/* Preview modal */} - {previewItem && previewIdx != null && ( - 0} - hasNext={previewIdx < items.length - 1} - onClose={() => setPreviewIdx(null)} - onPrev={() => setPreviewIdx((i) => (i != null && i > 0 ? i - 1 : i))} - onNext={() => setPreviewIdx((i) => (i != null && i < items.length - 1 ? i + 1 : i))} + {editing && ( + setEditing(false)} + onSaved={async () => { setEditing(false); await reload() }} /> )}
) } -function FolderChip({ - label, count, active, onClick, -}: { label: string; count: number; active: boolean; onClick: () => void }) { - return ( - - ) -} - -// ── TagDistCard (hero) ─────────────────────────────────────────────────── +// ── pipeline ───────────────────────────────────────────────────────────── -function TagDistCard({ project, version }: { project: ProjectDetail; version: Version | null }) { +function PipelineCard({ project, version, latestTrain, trainProgress }: { + project: ProjectDetail + version: Version | null + latestTrain: Task | null + trainProgress: { step: number; total: number } | null +}) { const { t } = useTranslation() - const navigate = useNavigate() - const triggerWord = version?.trigger_word ?? '' - const [tags, setTags] = useState>([]) - const [uniqueTotal, setUniqueTotal] = useState(0) + const stats = version?.stats + const train = stats?.train_image_count ?? 0 + const tagged = stats?.tagged_image_count ?? 0 + const reg = stats?.reg_image_count ?? 0 + const preparing = version?.status === 'preparing' + const ci = version ? (preparing ? phaseIndex(version.phase) : PHASE_ORDER.length) : -1 + const tagsDone = train > 0 && tagged >= train + + type State = 'done' | 'cur' | 'todo' + const phaseState = (phase: VersionPhase): State => { + if (!version) return 'todo' + const i = phaseIndex(phase) + if (!preparing) return phase === 'ready' ? (version.status === 'completed' ? 'done' : 'cur') : 'done' + return i < ci ? 'done' : i === ci ? 'cur' : 'todo' + } - useEffect(() => { - if (!version) { setTags([]); setUniqueTotal(0); return } - let cancelled = false - void api.listCaptionsFull(project.id, version.id) - .then((res) => { - if (cancelled) return - const counter = new Map() - for (const it of res.items) { - for (const tg of it.tags) counter.set(tg, (counter.get(tg) ?? 0) + 1) - } - const arr = Array.from(counter.entries()) - .map(([tag, n]) => ({ tag, n })) - .sort((a, b) => b.n - a.n || a.tag.localeCompare(b.tag)) - setUniqueTotal(arr.length) - setTags(arr) - }) - .catch(() => { - if (cancelled) return - setTags([]); setUniqueTotal(0) - }) - return () => { cancelled = true } - }, [project.id, version]) + const editState = phaseState('editing') + const tagState: State = editState === 'cur' ? (tagsDone ? 'done' : 'cur') : editState + const tagEditState: State = editState === 'cur' ? (tagsDone ? 'cur' : 'todo') : editState + const trainState = phaseState('ready') + + const cellStatus = (s: State) => (s === 'done' ? t('overview.cellDone') : s === 'cur' ? t('overview.cellCurrent') : '—') + + let trainCell = cellStatus(trainState) + let trainTitle: string | undefined + let trainPct = trainState === 'done' ? 100 : 0 + if (version && trainState !== 'todo' && version.status !== 'preparing') { + if (trainProgress) { + trainCell = t('overview.cellStep', { step: trainProgress.step.toLocaleString(), total: trainProgress.total.toLocaleString() }) + trainPct = Math.round((trainProgress.step / trainProgress.total) * 100) + } else if (latestTrain?.status === 'paused' && latestTrain.paused_step != null) { + trainCell = t('projects.pausedAt', { step: latestTrain.paused_step.toLocaleString() }) + } else if (latestTrain?.status === 'pending' || latestTrain?.status === 'scheduled') { + trainCell = t('overview.cellQueued') + } else if (version.status === 'failed') { + trainCell = t('overview.cellFailed') + trainTitle = version.last_failure_reason || latestTrain?.error_msg || undefined + } else if (version.status === 'canceled') { + trainCell = t('overview.cellCanceled') + } + } + + const steps: Array<{ + key: string; label: string; badge: string; state: State; pct: number + cell: string; cellTitle?: string; live?: boolean; to: string | null + }> = [ + { + key: 'download', label: t('overview.stepDownload'), badge: '·', + state: (project.download_image_count ?? 0) > 0 || train > 0 ? 'done' : 'todo', + pct: (project.download_image_count ?? 0) > 0 || train > 0 ? 100 : 0, + cell: t('projects.images', { n: (project.download_image_count ?? 0).toLocaleString() }), + to: `/projects/${project.id}/download`, + }, + { + key: 'curate', label: t('overview.stepCurate'), badge: '1', state: phaseState('curating'), + pct: phaseState('curating') === 'done' ? 100 : 0, + cell: version ? t('projects.images', { n: train.toLocaleString() }) : '—', + to: version && canGoPhase(version, 'curating') ? `/projects/${project.id}/v/${version.id}/curate` : null, + }, + { + key: 'preprocess', label: t('overview.stepPreprocess'), badge: '2', state: phaseState('preprocessing'), + pct: phaseState('preprocessing') === 'done' ? 100 : 0, + cell: cellStatus(phaseState('preprocessing')), + to: version && canGoPhase(version, 'preprocessing') ? `/projects/${project.id}/v/${version.id}/preprocess` : null, + }, + { + key: 'tag', label: t('overview.stepTag'), badge: '3', state: tagState, + pct: train > 0 ? Math.min(100, Math.round((tagged / train) * 100)) : 0, + cell: version ? `${tagged.toLocaleString()} / ${train.toLocaleString()}` : '—', + to: version && canGoPhase(version, 'editing') ? `/projects/${project.id}/v/${version.id}/edit` : null, + }, + { + key: 'edit', label: t('overview.stepTagEdit'), badge: '3', state: tagEditState, + pct: tagEditState === 'done' ? 100 : 0, + cell: cellStatus(tagEditState), + to: version && canGoPhase(version, 'editing') ? `/projects/${project.id}/v/${version.id}/edit` : null, + }, + { + key: 'reg', label: t('overview.stepReg'), badge: '4', state: phaseState('regularizing'), + pct: phaseState('regularizing') === 'done' ? 100 : 0, + cell: reg > 0 ? t('projects.images', { n: reg.toLocaleString() }) + : phaseState('regularizing') === 'done' ? t('overview.cellSkipped') : cellStatus(phaseState('regularizing')), + to: version && canGoPhase(version, 'regularizing') ? `/projects/${project.id}/v/${version.id}/reg` : null, + }, + { + key: 'train', label: t('overview.stepTrain'), badge: '5', state: trainState, pct: trainPct, + cell: trainCell, cellTitle: trainTitle, live: !!trainProgress, + to: version && canGoPhase(version, 'ready') ? `/projects/${project.id}/v/${version.id}/train` : null, + }, + ] - const max = useMemo(() => Math.max(1, ...tags.map((t) => t.n)), [tags]) + const cursorN = version ? PHASE_STEP[preparing ? version.phase : 'ready'].n : null return ( - navigate(`/projects/${project.id}/v/${version.id}/edit`), - disabled: !canGoVersionPhase(version, 'editing'), - } : undefined} - phase={`${t('nav.tag')} → ${t('nav.tagEdit')}`} - > - {tags.length === 0 ? ( -

- {t('overview.detail.emptyTag')} -

- ) : ( -
- {tags.map((row) => { - const pct = row.n / max - const isTrigger = !!triggerWord && row.tag === triggerWord - return ( -
-
- {isTrigger ? '★ ' : ''} - {row.n} -
+
+
+
+
{t('overview.pipelineProgress')}
+
+ {version ? t('overview.pipelineSub', { label: version.label, n: cursorN }) : t('overview.noVersion')} +
+
+
+
+
+ {steps.map((s) => { + const body = ( + <> + + + + {s.state === 'done' ? Icon.check : s.badge} + + {s.label} + + {s.cell} + ) + const style: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 7, minWidth: 0 } + return s.to + ? {body} + : {body} })}
- )} - +
+
) } -// ── HistTile / RegTile (下排 3 tile) ───────────────────────────────────── - -function HistTileCard({ - title, bins, action, phase, emptyHint, -}: { - title: string - bins: Array<{ key?: string; label: string; n: number }> - action?: { label: string; onClick: () => void; disabled?: boolean } - phase?: string - emptyHint: string -}) { - return ( - - {bins.length === 0 ? ( -

- {emptyHint} -

- ) : ( -
- -
- )} -
- ) +// ── KPIs ───────────────────────────────────────────────────────────────── + +function ckptLabel(t: (k: string, o?: Record) => string, name: string): string { + const ema = /[_-]ema\b/i.test(name.replace(/\.safetensors$/i, '')) + const epoch = name.match(/epoch[_-]?0*(\d+)/i) ?? name.match(/-0*(\d+)\.safetensors$/i) + const step = name.match(/step[_-]?0*(\d+)/i) + let label = step ? t('overview.ckptStep', { n: Number(step[1]).toLocaleString() }) + : epoch ? t('overview.ckptEpoch', { n: Number(epoch[1]) }) + : t('overview.ckptFinal') + if (ema) label += ' · EMA' + return label } -function RegTileCard({ - regCount, onGoReg, disabled, -}: { - regCount: number - onGoReg: () => void - disabled?: boolean +function Kpis({ project, version, uniqueTags, loraRows }: { + project: ProjectDetail + version: Version | null + uniqueTags: number | null + loraRows: LoraRow[] }) { const { t } = useTranslation() - if (regCount > 0) { - return ( - -

- {t('overview.detail.regCount', { n: regCount })} -

-
- ) - } + const stats = version?.stats + const train = stats?.train_image_count ?? 0 + const tagged = stats?.tagged_image_count ?? 0 + + // Train-set delta against the version created right before this one. + const prev = version + ? [...project.versions] + .filter((v) => v.created_at < version.created_at && v.stats) + .sort((a, b) => b.created_at - a.created_at)[0] ?? null + : null + const delta = prev && stats ? train - (prev.stats?.train_image_count ?? 0) : null + + const dash = version?.label.indexOf('-') ?? -1 + const labelHead = version ? (dash > 0 ? version.label.slice(0, dash) : version.label) : '—' + const labelTail = version && dash > 0 ? version.label.slice(dash) : '' + const isActive = version != null && version.id === project.active_version_id + const state = version + ? version.status === 'preparing' + ? t('overview.kpiPhase', { phase: splitOptional(t(PHASE_STEP[version.phase].labelKey)).short }) + : t(`versionStatus.${version.status}`) + : '' + return ( - -
-
∅
- - {t('overview.detail.regEmptyHint')} - +
+
+
+ {Icon.image} + {delta != null && prev && ( + + 0 ? 'ds-up' : delta < 0 ? 'ds-down' : 'ds-mute'}`}> + {t('overview.kpiDelta', { n: `${delta > 0 ? '+' : delta < 0 ? '−' : '±'}${Math.abs(delta).toLocaleString()}`, label: prev.label })} + + + )} +
+
{version ? train.toLocaleString() : '—'}
+
{t('overview.kpiTrain')}
- - ) -} - -// ── DetailGrid (2 hero + 3 tile) ───────────────────────────────────────── -function DetailGrid({ project, version }: { project: ProjectDetail; version: Version | null }) { - const { t } = useTranslation() - const navigate = useNavigate() - const vid = version?.id - - // ADR 0010: preprocess 已下沉 version scope;project 概览 hist 用 active - // version 的 train 数据。没 active version → 空。 - const [preprocessItems, setPreprocessItems] = useState>([]) - useEffect(() => { - if (vid == null) { setPreprocessItems([]); return } - let cancelled = false - void api.listPreprocessFilesTrain(project.id, vid) - .then((res) => { - if (cancelled) return - setPreprocessItems(res.images.map((i) => ({ w: i.w, h: i.h }))) - }) - .catch(() => { if (!cancelled) setPreprocessItems([]) }) - return () => { cancelled = true } - }, [project.id, vid]) - - // crop workspace - 长宽比 hist 数据源(train scope) - const [cropItems, setCropItems] = useState>([]) - useEffect(() => { - if (vid == null) { setCropItems([]); return } - let cancelled = false - void api.listCropWorkspaceTrain(project.id, vid) - .then((res) => { - if (cancelled) return - setCropItems(res.images.map((i) => ({ w: i.w, h: i.h }))) - }) - .catch(() => { if (!cancelled) setCropItems([]) }) - return () => { cancelled = true } - }, [project.id, vid]) - - const pixelBins = useMemo( - () => computePixelHist(preprocessItems).map((b) => ({ key: b.id, label: b.label, n: b.n })), - [preprocessItems], - ) - const arBins = useMemo(() => { - const m = new Map() - for (const im of cropItems) { - if (im.w <= 0 || im.h <= 0) continue - const { label, sortKey } = arBucket(im.w / im.h) - const prev = m.get(label) - m.set(label, { label, sortKey, n: (prev?.n ?? 0) + 1 }) - } - return Array.from(m.values()) - .sort((a, b) => b.sortKey - a.sortKey) - .map((b) => ({ label: b.label, n: b.n })) - }, [cropItems]) - - const regCount = version?.stats?.reg_image_count ?? 0 +
+
+ {Icon.tag} + {version && train > 0 && ( + + + {tagged >= train ? t('overview.kpiAllCaptioned') : t('overview.kpiMissingCaptions', { n: (train - tagged).toLocaleString() })} + + + )} +
+
{version ? tagged.toLocaleString() : '—'}{version && / {train.toLocaleString()}}
+
+ {uniqueTags != null ? t('overview.kpiCaptionedUnique', { n: uniqueTags.toLocaleString() }) : t('overview.kpiCaptioned')} +
+
- return ( -
-
- - +
+
+ {Icon.branch} + + {t('overview.kpiVersionsTotal', { v: t('projects.versions', { count: project.versions.length }) })} + +
+
{labelHead}{labelTail && {labelTail}}
+
{t(isActive ? 'overview.kpiActiveVersion' : 'overview.kpiShownVersion', { state })}
-
- navigate(`/projects/${project.id}/v/${version.id}/preprocess?tool=upscale`) } : undefined} - phase={`${t('nav.preprocess')}`} - /> - navigate(`/projects/${project.id}/v/${version.id}/preprocess?tool=crop`) } : undefined} - phase={`${t('nav.preprocess')}`} - /> - version && navigate(`/projects/${project.id}/v/${version.id}/reg`)} - disabled={!canGoVersionPhase(version, 'regularizing')} - /> + +
+
+ {Icon.hex(15)} + + + {loraRows.length > 0 ? t('overview.kpiLastCkpt', { label: ckptLabel(t, loraRows[0].name) }) : t('overview.kpiNoCkpts')} + + +
+
{loraRows.length}
+
{t('overview.kpiCkpts')}
) } -// ── Tasks / Output 面板(version scope,沿用) ─────────────────────────── - -const TASK_STATUS_BADGE: Record = { - pending: 'neutral', running: 'accent', paused: 'warn', - done: 'ok', failed: 'err', canceled: 'neutral', +// ── Tasks tab ──────────────────────────────────────────────────────────── + +function useWhen() { + const { t, i18n } = useTranslation() + const loc = i18n.language + const today = startOfDay(Date.now() / 1000) + return { + /** Task start: clock time today, "yesterday", else the date. */ + start: (ts: number | null | undefined) => { + if (!ts) return '—' + if (ts >= today) return fmtClock(ts, loc) + if (ts >= today - DAY) return t('overview.yesterday') + return fmtDate(ts, loc) + }, + /** File ready: "today", "yesterday", else the date. */ + day: (ts: number) => (ts >= today ? t('overview.today') : ts >= today - DAY ? t('overview.yesterday') : fmtDate(ts, loc)), + duration: (task: Task) => { + if (!task.started_at) return '—' + const end = task.finished_at ?? (task.status === 'running' ? Date.now() / 1000 : task.paused_at ?? null) + if (end == null) return '—' + const mins = Math.floor(Math.max(0, end - task.started_at) / 60) + if (mins < 1) return t('overview.durLess') + if (mins < 60) return t('overview.durMin', { m: mins }) + return t('overview.durHours', { h: Math.floor(mins / 60), m: mins % 60 }) + }, + } } -function VersionTasksPanel({ projectId, versionId }: { projectId: number; versionId: number | null }) { +function TaskStatusBadge({ task }: { task: Task }) { const { t } = useTranslation() - const navigate = useNavigate() - const [tasks, setTasks] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - setLoading(true) - void api.listQueue() - .then((items) => { - if (cancelled) return - const filtered = items - .filter((tk) => tk.project_id === projectId && (versionId == null || tk.version_id === versionId)) - .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) - setTasks(filtered) - }) - .catch(() => { if (!cancelled) setTasks([]) }) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [projectId, versionId]) + switch (task.status) { + case 'running': + return {t('status.running')} + case 'failed': + return {t('status.failed')} + case 'pending': + return {t('status.pending')} + case 'scheduled': + return {t('status.scheduled')} + case 'paused': + return {t('status.paused')} + case 'canceled': + return {t('status.canceled')} + default: + return {t('status.done')} + } +} - if (loading) return
{t('common.loading')}
- if (tasks.length === 0) return
{t('overview.tasksEmpty')}
+function TasksTable({ project, tasks, loaded, onOpen, onPause, onCancel }: { + project: ProjectDetail + tasks: Task[] + loaded: boolean + onOpen: (id: number, hash?: string) => void + onPause: (id: number) => void + onCancel: (task: Task) => void +}) { + const { t } = useTranslation() + const when = useWhen() + if (!loaded) return
{t('common.loading')}
+ if (tasks.length === 0) return
{t('overview.tasksNone')}
- const fmtTime = (ts: number | null) => ts ? new Date(ts * 1000).toLocaleString() : '—' + const labelOf = (vid: number | null | undefined) => project.versions.find((v) => v.id === vid)?.label ?? null return ( -
- - - - - - - +
+
{t('overview.tasksTable.name')}{t('overview.tasksTable.status')}{t('overview.tasksTable.started')}{t('overview.tasksTable.finished')}
+ + + + + + + + - {tasks.map((tk) => ( - navigate(`/queue/${tk.id}`)} - > - - - - - - ))} + {tasks.map((task) => { + const kind = task.task_type ?? 'train' + const kindKey = kind.startsWith('eval_') ? 'eval' : kind + const label = labelOf(task.version_id) + const train = kind === 'train' + const live = task.status === 'running' || task.status === 'pending' || task.status === 'scheduled' + const items: KebabItem[] = [ + { label: t('projects.openTask'), onSelect: () => onOpen(task.id) }, + { label: t('overview.banner.viewLog'), onSelect: () => onOpen(task.id, '#log') }, + ] + if (train && (task.status === 'running' || task.status === 'paused')) { + items.push({ label: t('overview.banner.openMonitor'), onSelect: () => onOpen(task.id, '#monitor') }) + } + if (task.status === 'running' && task.is_pausable) { + items.push({ label: t('overview.banner.pause'), onSelect: () => onPause(task.id) }) + } + if (live) { + items.push({ + label: train ? t('overview.banner.cancelTraining') : t('overview.cancelTask'), + onSelect: () => onCancel(task), tone: 'err', + }) + } + return ( + + + + + + + + + ) + })}
{t('projects.colTask')}{t('overview.colVersion')}{t('projects.colStatus')}{t('overview.colStarted')}{t('overview.colDuration')}
#{tk.id} {tk.name}{tk.status}{fmtTime(tk.started_at ?? null)}{fmtTime(tk.finished_at ?? null)}
+ + #{task.id} + {t(`projects.taskKind.${kindKey}`, { defaultValue: kind })}{kind} + + {label ?? '—'}{when.start(task.started_at)}{when.duration(task)}
) } -function VersionOutputPanel({ - version, latestTask, -}: { +// ── LoRA files tab ─────────────────────────────────────────────────────── + +function FilesTable({ version, rows, outputs, taskId, onRender }: { version: Version | null - latestTask: Task | null + rows: LoraRow[] + outputs: TaskOutputs | null + taskId: number | null + onRender: (row: LoraRow) => void }) { const { t } = useTranslation() - if (!version) return
{t('overview.outputEmpty')}
- if (!latestTask) return
{t('overview.outputEmptyVersion')}
- // 复用 QueueDetail OutputsTab:列表 + 排序 + 单文件下载 + 批量打 zip + 打开 - // 文件夹 + 导出 data_exports(跟 task 详情页同款行为) - return -} - -// ── Main ───────────────────────────────────────────────────────────────── + const { toast } = useToast() + const navigate = useNavigate() + const when = useWhen() + if (!version) return
{t('overview.outputEmpty')}
+ if (rows.length === 0) return
{t('overview.outputEmptyVersion')}
-export default function ProjectOverview() { - const { t } = useTranslation() - const { project, activeVersion } = useOutletContext() - const ctx = useProjectCtx() + const total = rows.reduce((s, r) => s + (r.size ?? 0), 0) + const sized = rows.every((r) => r.size != null) + const finalPath = version.output_lora_path ? normPath(version.output_lora_path) : null + const relPaths = rows.flatMap((r) => (r.relPath ? [r.relPath] : [])) - // 初值优先级:URL `?version=N` (从 /queue 项目链接跳来时带) → project.active_version_id → activeVersion - // 读完 URL 后用 history.replaceState 清掉 query,避免刷新覆盖用户后续在 dropdown 选的版本 - const [selectedVid, setSelectedVid] = useState(() => { - try { - const sp = new URLSearchParams(window.location.search) - const v = sp.get('version') - if (v) { - const n = Number(v) - if (Number.isFinite(n)) return n - } - } catch { /* ignore */ } - return project.active_version_id ?? activeVersion?.id ?? null - }) - useEffect(() => { + const openFolder = async () => { + if (taskId == null) return try { - const url = new URL(window.location.href) - if (url.searchParams.has('version')) { - url.searchParams.delete('version') - window.history.replaceState({}, '', url.toString()) - } - } catch { /* ignore */ } - }, []) - useEffect(() => { - const stillExists = project.versions.some((v) => v.id === selectedVid) - if (!stillExists) setSelectedVid(project.active_version_id ?? null) - }, [project.versions, project.active_version_id, selectedVid]) + const r = await api.openTaskFolder(taskId) + toast(t('queueDetail.folderOpened', { path: r.opened }), 'success') + } catch (e) { toast(String(e), 'error') } + } + const copyPath = async (path: string) => { + try { await navigator.clipboard.writeText(path); toast(t('queueDetail.pathCopied'), 'success') } + catch { toast(t('queueDetail.copyFailed'), 'error') } + } - const selectedVersion: Version | null = - project.versions.find((v) => v.id === selectedVid) ?? null + return ( + <> +
+ + output/ · {t('projects.ckpts', { count: rows.length })}{sized ? ` · ${fmtBytes(t, total)}` : ''} + + + {taskId != null && relPaths.length > 0 && ( + + {t('overview.downloadAllZip')} + + )} + {taskId != null && outputs?.supports_open_folder && ( + + )} +
+
+ + + + + + + + + + + {rows.map((row) => { + const isFinal = finalPath != null && normPath(row.path) === finalPath + const items: KebabItem[] = [] + if (taskId != null && row.relPath) { + items.push({ + label: t('common.download'), + onSelect: () => { + const a = document.createElement('a') + a.href = api.taskOutputDownloadUrl(taskId, row.relPath!) + a.download = row.name + a.click() + }, + }) + } + items.push({ label: t('overview.copyPath'), onSelect: () => void copyPath(row.path) }) + if (taskId != null) { + items.push({ label: t('overview.allOutputs'), onSelect: () => navigate(`/queue/${taskId}#outputs`) }) + } + return ( + + + + + + + + ) + })} + +
{t('common.file')}{t('overview.colVersion')}{t('common.size')}{t('overview.colReady')} +
+ + {Icon.hex(13)} + + {row.name} + {ckptLabel(t, row.name)}{isFinal ? ` · ${t('overview.ckptResult')}` : ''} + + + {version.label}{row.size != null ? fmtBytes(t, row.size) : '—'}{when.day(row.mtime)} + + + + +
+
+ + ) +} - // 项目全部 task(最近优先)— 右栏 Recent runs + banner / Output 数据源 - const [projTasks, setProjTasks] = useState([]) - const loadProjectTasks = useCallback(() => { - let cancelled = false - void api.listQueue() - .then((items) => { - if (cancelled) return - const list = items - .filter((tk) => tk.project_id === project.id) - .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) - setProjTasks(list) - }) - .catch(() => { if (!cancelled) setProjTasks([]) }) - return () => { cancelled = true } - }, [project.id]) - useEffect(() => loadProjectTasks(), [loadProjectTasks]) +// ── right column ───────────────────────────────────────────────────────── - useEventStream((evt) => { - if (evt.type === 'train_loop_started' - || evt.type === 'auto_epoch_backup_written' - || evt.type === 'task_state_changed') { - loadProjectTasks() +function VersionsCard({ project, selectedVid, tasks, onOpen }: { + project: ProjectDetail + selectedVid: number | null + tasks: Task[] + onOpen: (v: Version) => void +}) { + const { t } = useTranslation() + const active = project.versions.find((v) => v.id === project.active_version_id) ?? null + const versions = [...project.versions].sort((a, b) => b.created_at - a.created_at) + + const badgeOf = (v: Version) => { + const last = tasks.find((tk) => tk.version_id === v.id && isTrainTask(tk)) + if (v.status === 'training') { + if (last?.status === 'paused') return {t('overview.verPaused')} + if (last?.status === 'pending' || last?.status === 'scheduled') return {t('status.queued')} + return {t('overview.verTraining')} } - }) - - const latestTask = useMemo( - () => projTasks.find((tk) => tk.version_id === selectedVid) ?? null, - [projTasks, selectedVid], - ) - - const [activeTab, setActiveTab] = useState('details') - const navigate = useNavigate() + if (v.status === 'completed') return {t('overview.verTrained')} + if (v.status === 'failed') return {t('versionStatus.failed')} + if (v.status === 'canceled') return {t('versionStatus.canceled')} + return {t('overview.verPreparing', { phase: splitOptional(t(PHASE_STEP[v.phase].labelKey)).short })} + } - const tabBtnCls = (tab: OverviewTab) => 'tab ' + (activeTab === tab ? 'is-active' : '') + const titleOf = (v: Version) => { + if (v.id === project.active_version_id) return t('overview.openVersion') + if (v.status === 'completed' && v.output_lora_path) return t('overview.viewOutput') + return t('overview.activateAndOpen') + } return ( -
- +
+
+
+
{t('overview.versions')}
+
+ {t('projects.versions', { count: project.versions.length })} + {active && <>{' · '}{t('overview.activeLabel')} {active.label}} +
+
+
+
+ {versions.length === 0 &&
{t('overview.noVersion')}
} + {versions.map((v) => { + const s = v.stats + const meta: string[] = [] + if (s) { + meta.push(t('overview.verTrain', { n: s.train_image_count.toLocaleString() })) + meta.push(s.reg_image_count > 0 ? t('overview.verReg', { n: s.reg_image_count.toLocaleString() }) : t('overview.verNoReg')) + if (s.has_output) meta.push(t('overview.verCkpt')) + } + return ( - - - } - /> + ) + })} +
+
+ ) +} -
-
- {/* ── 左列:pipeline → (task banner) → versions → details ──── */} -
- - - {selectedVersion && ( - setActiveTab('output')} - /> - )} +const FOLDER_COLORS = ['#a3db52', '#cfe3ad', '#e3ecd4'] +const OTHER_COLOR = '#eef1e8' +const REG_COLOR = '#d4d5d1' -
-
- {t('overview.versionsCaption')} · {project.versions.length} -
-
- {project.versions.map((v) => ( - setSelectedVid(vid)} - /> - ))} -
-
+function CompositionCard({ version, sizes }: { + version: Version | null + sizes: Array<{ w: number; h: number }> | null +}) { + const { t } = useTranslation() + const stats = version?.stats + const train = stats?.train_image_count ?? 0 + const reg = stats?.reg_image_count ?? 0 + const total = train + reg + + // Donut: the three biggest train folders, the rest grouped, then reg. + const folders = [...(stats?.train_folders ?? [])].filter((f) => f.image_count > 0).sort((a, b) => b.image_count - a.image_count) + const segs: Array<{ label: string; n: number; color: string }> = folders.slice(0, 3) + .map((f, i) => ({ label: f.name, n: f.image_count, color: FOLDER_COLORS[i] })) + const rest = folders.slice(3).reduce((s, f) => s + f.image_count, 0) + if (rest > 0) segs.push({ label: t('overview.compOther'), n: rest, color: OTHER_COLOR }) + if (reg > 0) segs.push({ label: t('overview.compReg'), n: reg, color: REG_COLOR }) + const segTotal = segs.reduce((s, x) => s + x.n, 0) + let offset = 25 + const arcs = segs.map((s) => { + const len = segTotal > 0 ? (s.n / segTotal) * 100 : 0 + const arc = { ...s, dash: `${len} ${100 - len}`, offset } + offset -= len + return arc + }) -
-
- - - -
- {activeTab === 'details' && ( -
- + // Resolution buckets: top four sizes, the rest grouped. + const buckets = useMemo(() => { + if (!sizes || sizes.length === 0) return [] + const m = new Map() + for (const s of sizes) { + const k = `${s.w}×${s.h}` + const prev = m.get(k) + m.set(k, { w: s.w, h: s.h, n: (prev?.n ?? 0) + 1 }) + } + const sorted = Array.from(m.values()).sort((a, b) => b.n - a.n) + const out = sorted.slice(0, 4).map((b) => ({ + label: b.w === b.h ? `${b.w}²` : `${b.w}×${b.h}`, title: `${b.w}×${b.h} · ${b.n}`, n: b.n, mute: false, + })) + const other = sorted.slice(4).reduce((s, b) => s + b.n, 0) + if (other > 0) out.push({ label: t('overview.compOther'), title: `${t('overview.compOther')} · ${other}`, n: other, mute: true }) + return out + }, [sizes, t]) + const maxBucket = Math.max(1, ...buckets.map((b) => b.n)) + + return ( +
+
+
+
{t('overview.compTitle')}
+
+ {version ? t('overview.compSub', { total: total.toLocaleString(), train: train.toLocaleString(), reg: reg.toLocaleString() }) : t('overview.noVersion')} +
+
+
+
+ {segTotal === 0 ? ( +
{t('overview.compEmpty')}
+ ) : ( +
+ +
+ {segs.map((s) => ( +
+ + {s.label} + + {s.n.toLocaleString()}
- )} - {activeTab === 'tasks' && ( - - )} - {activeTab === 'output' && ( - - )} + ))}
- - {/* ── 右栏:dataset 统计 + recent runs ──── */} -
-
-
{t('overview.datasetCaption')}
- {([ - [t('overview.dataset.pool'), project.download_image_count ?? 0], - [t('overview.dataset.train'), selectedVersion?.stats?.train_image_count ?? 0], - [t('overview.dataset.reg'), selectedVersion?.stats?.reg_image_count ?? 0], - [t('overview.dataset.tagged'), selectedVersion?.stats?.tagged_image_count ?? 0], - ] as Array<[string, number]>).map(([k, v], i, arr) => ( -
- {k} - {v} -
+ )} + {buckets.length > 0 && ( + <> +
{t('overview.compResBuckets')}
+
+ {buckets.map((b) => ( + ))}
+
{buckets.map((b) => {b.label})}
+ + )} +
+
+ ) +} -
-
{t('overview.recentRunsCaption')}
-
- {projTasks.length === 0 && ( - {t('overview.noRuns')} - )} - {projTasks.slice(0, 6).map((tk) => ( - - ))} -
-
+function TopTagsCard({ version, tagCounts, train, tagged }: { + version: Version | null + tagCounts: Array<{ tag: string; n: number }> | null + train: number + tagged: number + reg: number +}) { + const { t } = useTranslation() + const trigger = version?.trigger_word?.trim() ?? '' + const top = (tagCounts ?? []).slice(0, 6) + const base = Math.max(1, train, ...top.map((x) => x.n)) + + return ( +
+
+
+
{t('overview.topTags')}
+
+ {version + ? t('overview.topTagsSub', { tagged: tagged.toLocaleString(), total: train.toLocaleString(), unique: (tagCounts?.length ?? 0).toLocaleString() }) + : t('overview.noVersion')}
+
+ {top.length === 0 &&
{t('overview.topTagsEmpty')}
} + {top.map((row) => { + const isTrigger = trigger !== '' && row.tag === trigger + return ( +
+
+ {row.tag} + {row.n.toLocaleString()} +
+ +
+ ) + })} +
+
+ ) +} + +// ── edit project ───────────────────────────────────────────────────────── + +function EditProjectDialog({ project, onClose, onSaved }: { + project: ProjectDetail + onClose: () => void + onSaved: () => Promise +}) { + const { t } = useTranslation() + const { toast } = useToast() + const [title, setTitle] = useState(project.title) + const [note, setNote] = useState(project.note ?? '') + const [busy, setBusy] = useState(false) + + const save = async () => { + if (!title.trim()) return + setBusy(true) + try { + await api.updateProject(project.id, { title: title.trim(), note: note.trim() }) + toast(t('overview.saved'), 'success') + await onSaved() + } catch (e) { + toast(String(e), 'error') + } finally { + setBusy(false) + } + } + + return ( +
{ if (e.target === e.currentTarget && !busy) onClose() }} + onKeyDown={(e) => { if (e.key === 'Escape' && !busy) onClose() }} + > +
{ e.preventDefault(); void save() }} + > +
{t('overview.editProject')}
+ +