Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/desktop/e2e/groups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,18 @@ test('a serial group across projects waits for each step, then stops everything'
const detail = page.getByRole('region', { name: '任务运行 front1' })
await expect(detail).toContainText('输出出现文字「API ready」')
await item.getByRole('button', { name: '执行 front1' }).click()
// The tab signals a running group even while the projects tab is shown.
// The tab, a bar above the tabs and the title bar name the group while it starts...
const running = page.getByRole('tab', { name: /批量/ }).getByRole('status')
await expect(running).toBeVisible()
const bar = page.getByRole('region', { name: '正在运行的批量任务' })
const titleBar = page.getByRole('banner').getByRole('button', { name: /^正在运行:/ })
await expect(running).toHaveAttribute('aria-label', /front1(启动中 \d\/2)/)
await expect(bar).toContainText(/front1启动中 \d\/2/)
await expect(titleBar).toContainText('front1')
await expect(item).toContainText('已完成', { timeout: 30_000 })
await expect(running).toBeHidden()
// ...and while the servers it started keep running after the execution ended.
await expect(running).toHaveAttribute('aria-label', '正在运行:front1(运行中 2/2)')
await expect(bar).toContainText('front1运行中 2/2')
await expect(titleBar).toContainText('front1运行中 2/2')
await expect(detail).toContainText('2/2 个步骤完成')
await expect(detail).toContainText('服务进程可能仍在运行')
await expect(detail.getByRole('button', { name: '停止全部' })).toBeEnabled()
Expand All @@ -80,6 +87,8 @@ test('a serial group across projects waits for each step, then stops everything'
await expect(
page.locator('main').getByRole('button', { name: 'serve', exact: true })
).toHaveAttribute('aria-current', 'true')
// The script row tells which batch run started it.
await expect(page.locator('main').getByText('批量:front1')).toBeVisible()

const runs = await listRuns(page)
expect(runs.filter((run) => run.status === 'running')).toHaveLength(2)
Expand All @@ -100,6 +109,9 @@ test('a serial group across projects waits for each step, then stops everything'
timeout: 20_000
})
.toBe(0)
await expect(bar).toBeHidden()
await expect(running).toBeHidden()
await expect(titleBar).toBeHidden()
})

test('a failing serial step stops the sequence and explains why', async ({
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ function App(): React.JSX.Element {
setDrawerOpen(false)
}}
onOpenNavigation={narrow ? () => setDrawerOpen(true) : undefined}
onSelectGroup={(groupId) => {
setSelectedGroupId(groupId)
setShowSettings(false)
}}
/>
<div className="flex min-h-0 flex-1">
{narrow ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { Loader2, Square, X as XIcon } from 'lucide-react'
import { Button } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils'
import { describeActiveGroup, type ActiveGroupView } from './use-active-groups'
import { useStopGroup } from './use-groups'

interface RunningGroupsBarProps {
groups: ActiveGroupView[]
onSelectGroup: (groupId: string) => void
}

/** Lists active batch runs above the sidebar tabs, so they stay visible on either tab. */
export function RunningGroupsBar({
groups,
onSelectGroup
}: RunningGroupsBarProps): React.JSX.Element | null {
const stop = useStopGroup()
const [error, setError] = useState<string | null>(null)
if (groups.length === 0) return null
return (
<section
className="flex shrink-0 flex-col gap-0.5 rounded-lg border bg-muted/40 p-1"
aria-label="正在运行的批量任务"
>
{error && (
<p className="flex items-start gap-1 px-1.5 text-xs text-destructive">
<span className="min-w-0 flex-1">{error}</span>
<button type="button" onClick={() => setError(null)} aria-label="关闭">
<XIcon className="size-3" />
</button>
</p>
)}
<ul className="flex flex-col gap-0.5">
{groups.map((group) => {
const stopping = stop.isPending && stop.variables === group.groupId
return (
<li key={group.groupId} className="flex items-center gap-1">
<button
type="button"
onClick={() => onSelectGroup(group.groupId)}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-left text-xs hover:bg-background"
title={`查看 ${group.name}`}
>
{group.phase === 'starting' ? (
<Loader2 className="size-3 shrink-0 animate-spin text-info" />
) : (
<span className="size-1.5 shrink-0 rounded-full bg-success" />
)}
<span className="truncate font-medium">{group.name}</span>
<span
className={cn(
'ml-auto shrink-0 tabular-nums text-muted-foreground',
group.phase === 'starting' && 'text-info'
)}
>
{describeActiveGroup(group)}
</span>
</button>
<Button
variant="ghost"
size="icon-xs"
disabled={stopping}
onClick={() => stop.mutate(group.groupId, { onError: (e) => setError(e.message) })}
aria-label={`停止 ${group.name}`}
title="停止全部(结束每个脚本的整个进程树)"
>
{stopping ? <Loader2 className="animate-spin" /> : <Square />}
</Button>
</li>
)
})}
</ul>
</section>
)
}
37 changes: 37 additions & 0 deletions apps/desktop/src/renderer/src/features/groups/use-active-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useMemo } from 'react'
import { activeGroups, type ActiveGroup } from '@devhub/shared'
import { useRuns } from '@renderer/features/runs/use-runs'
import { useGroupStates, useGroups } from './use-groups'

export interface ActiveGroupView extends ActiveGroup {
name: string
}

/** Batch runs still starting or with scripts alive, named after the current group. */
export function useActiveGroups(): ActiveGroupView[] {
const groups = useGroups().data
const states = useGroupStates().data
const runs = useRuns().data
return useMemo(
() =>
activeGroups(states ?? [], runs ?? []).map((active) => {
const state = states?.find((candidate) => candidate.groupId === active.groupId)
const name =
groups?.find((group) => group.id === active.groupId)?.name ??
state?.group?.name ??
'(已删除的批量任务)'
return { ...active, name }
}),
[groups, states, runs]
)
}

/** "启动中 1/3" or "运行中 2/3". */
export function describeActiveGroup(group: ActiveGroup): string {
return `${group.phase === 'starting' ? '启动中' : '运行中'} ${group.current}/${group.total}`
}

/** "前后端联调(运行中 2/3)、全量构建(启动中 0/2)" for tooltips and screen readers. */
export function describeActiveGroups(groups: ActiveGroupView[]): string {
return groups.map((group) => `${group.name}(${describeActiveGroup(group)})`).join('、')
}
116 changes: 74 additions & 42 deletions apps/desktop/src/renderer/src/features/scripts/script-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from '@renderer/comp
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { Skeleton } from '@renderer/components/ui/skeleton'
import { useActiveGroups, type ActiveGroupView } from '@renderer/features/groups/use-active-groups'
import { RunControls } from '@renderer/features/runs/run-controls'
import { RunHistoryButton } from '@renderer/features/runs/run-history-button'
import { useRuns } from '@renderer/features/runs/use-runs'
Expand All @@ -20,6 +21,7 @@ interface ScriptListProps {
export function ScriptList({ project, onRunStarted }: ScriptListProps): React.JSX.Element {
const scripts = useProjectScripts(project.id)
const runs = useRuns()
const activeGroups = useActiveGroups()
// `retry` is set for a taken port: the user may start the script anyway.
const [runError, setRunError] = useState<{ error: Error; retry?: () => void } | null>(null)

Expand Down Expand Up @@ -122,6 +124,7 @@ export function ScriptList({ project, onRunStarted }: ScriptListProps): React.JS
source={source}
scripts={group}
runs={runs.data ?? []}
activeGroups={activeGroups}
onRunError={(error, retry) => setRunError({ error, retry })}
onRunStarted={onRunStarted}
/>
Expand All @@ -137,6 +140,8 @@ interface ScriptGroupProps {
source: ScriptSource
scripts: Script[]
runs: Run[]
/** Labels scripts that a batch run started or reused. */
activeGroups: ActiveGroupView[]
onRunError: (error: Error, retry?: () => void) => void
onRunStarted: (run: Run) => void
}
Expand All @@ -146,6 +151,7 @@ function ScriptGroup({
source,
scripts,
runs,
activeGroups,
onRunError,
onRunStarted
}: ScriptGroupProps): React.JSX.Element {
Expand All @@ -159,53 +165,79 @@ function ScriptGroup({
{executable && <Badge variant="secondary">{executable}</Badge>}
</h3>
<ul className="divide-y rounded-lg border bg-card">
{scripts.map((script) => (
<li
key={script.id}
className="flex items-center gap-4 px-4 py-2.5 max-md:gap-2 max-md:px-3"
>
{/* On phones the name goes above the command, leaving the row's width to the controls. */}
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-4">
<span className="truncate font-medium md:w-40 md:shrink-0" title={script.name}>
{script.name}
</span>
<div className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<code className="truncate font-mono text-xs" title={script.command}>
{script.command}
</code>
{script.ports?.map((port) => (
{scripts.map((script) => {
const run = runs.find(
(candidate) =>
candidate.kind === 'script' &&
candidate.projectId === projectId &&
candidate.scriptId === script.id
)
const groups = run ? activeGroups.filter((group) => group.runIds.includes(run.id)) : []
return (
<li
key={script.id}
className="flex items-center gap-4 px-4 py-2.5 max-md:gap-2 max-md:px-3"
>
{/* On phones the name goes above the command, leaving the row's width to the controls. */}
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-4">
<span className="flex min-w-0 items-center gap-1.5 md:w-40 md:shrink-0">
<span className="truncate font-medium" title={script.name}>
{script.name}
</span>
{groups.length > 0 && (
<Badge
key={port}
variant="outline"
className="h-4 shrink-0 px-1.5 text-[10px]"
title="启动前会检查这个端口是否已被占用"
variant="secondary"
className="h-4 min-w-0 shrink px-1.5 text-[10px] text-info"
title={`由批量任务启动或纳入:${groups.map((group) => group.name).join('、')}`}
>
端口 {port}
<span className="truncate">
批量:{groups.map((group) => group.name).join('、')}
</span>
</Badge>
))}
)}
</span>
{script.description && (
<p className="truncate text-xs text-muted-foreground" title={script.description}>
{script.description}
</p>
)}
<div className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<code className="truncate font-mono text-xs" title={script.command}>
{script.command}
</code>
{script.ports?.map((port) => (
<Badge
key={port}
variant="outline"
className="h-4 shrink-0 px-1.5 text-[10px]"
title="启动前会检查这个端口是否已被占用"
>
端口 {port}
</Badge>
))}
</span>
{script.description && (
<p
className="truncate text-xs text-muted-foreground"
title={script.description}
>
{script.description}
</p>
)}
</div>
</div>
</div>
<RunHistoryButton projectId={projectId} scriptId={script.id} scriptName={script.name} />
<RunControls
projectId={projectId}
scriptId={script.id}
scriptName={script.name}
run={runs.find(
(run) =>
run.kind === 'script' && run.projectId === projectId && run.scriptId === script.id
)}
onError={onRunError}
onStarted={onRunStarted}
/>
</li>
))}
<RunHistoryButton
projectId={projectId}
scriptId={script.id}
scriptName={script.name}
/>
<RunControls
projectId={projectId}
scriptId={script.id}
scriptName={script.name}
run={run}
onError={onRunError}
onStarted={onRunStarted}
/>
</li>
)
})}
</ul>
</section>
)
Expand Down
Loading
Loading