Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 3cacaa177064da3e59457c391a5f63469f70537c
parent dbeca16b6375651041928e567e970a97970ac50f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 22 May 2026 08:51:31 -0400

fix actionable page, make active/jobs badges not redundant

Diffstat:
Meditor/CHANGELOG.md | 2++
Meditor/app/actionable/actions.ts | 40++++++++++++++++++++++++++++------------
Meditor/app/jobs/active/page.tsx | 4++++
Meditor/app/layout.tsx | 27+++++++++++++++++++--------
Aeditor/e2e/jobs-active-order.spec.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 112 insertions(+), 20 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -20,6 +20,8 @@ ### Changed - "Recent changes" card on the dashboard now clamps to a fixed height with a gradient fade at the bottom; the entire card is a link to `/changelog`. - `/channels` rows are now dimmed when a channel is excluded from Sync all, matching the existing treatment for build exclusion. +- `/jobs/active` now lists running jobs above queued jobs — both within each channel section and in the section order itself. Channels with running work appear before queued-only channels so the live jobs stay at the top of the page without scrolling. Ordering within each status group still follows the existing `queuedAt`-descending tiebreaker. +- Sidebar nav badges are no longer redundant: **Jobs** continues to count running + queued, while **Active** now counts only running jobs. ### Fixed - Channel page: clicking a pipeline stage in the side rail (or a mobile stage badge) no longer jumps up into the selected video's viewer panel. The per-video stage cards were emitting the same DOM ids as the channel-level pipeline sections, so the browser scrolled to the first match. The per-video cards now use a `video-stage-` prefix. diff --git a/editor/app/actionable/actions.ts b/editor/app/actionable/actions.ts @@ -2,10 +2,6 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - detectPlatform, - platformQueueKey, -} from "yt-dlp-transcript-common/lib/platform"; import { listChannels } from "yt-dlp-transcript-common/controller/channels"; import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; @@ -16,6 +12,18 @@ export type RefreshAllResult = { skipped: { slug: string; reason: string }[]; }; +async function drainStream(stream: ReadableStream<string>): Promise<void> { + const reader = stream.getReader(); + try { + while (true) { + const { done } = await reader.read(); + if (done) return; + } + } finally { + reader.releaseLock(); + } +} + export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResult> { const paths = getPaths(); const channels = await listChannels(paths); @@ -32,17 +40,19 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu ); const queued: string[] = []; const skipped: { slug: string; reason: string }[] = []; + const streams: ReadableStream<string>[] = []; for (const c of channels) { if (active.has(c.slug)) { skipped.push({ slug: c.slug, reason: "already running" }); continue; } - const queueKey = platformQueueKey( - c.config.platform ?? detectPlatform(c.config.url), - ); const result = await runManagedFunction({ kind: "refresh-report", - queueKey, + // Empty queueKey: bypass queue serialization. Snapshot regen is a + // local filesystem scan that never touches the platform, so there's + // no reason for it to wait behind sync/download work. See + // registry.ts:69-72 for the documented escape hatch. + queueKey: "", paths, channelSlug: c.slug, fn: async (onLog) => { @@ -53,7 +63,10 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu `${snap.undownloadedIds.length} undownloaded · ` + `${snap.buckets.downloadedNoTranscript.length} awaiting transcription.`, ); - revalidatePath(`/channels/${c.slug}`); + // Deliberately no revalidatePath here — calling it from a + // background fn races with the in-flight re-render of /actionable + // that the action's own revalidatePath triggers. The action's + // single revalidate at the end picks up every fresh snapshot. }, }); if (!result.ok) { @@ -61,10 +74,13 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu continue; } queued.push(c.slug); - // The job keeps running and writes to disk via runManagedFunction's onLog; - // no consumer here means we drop the stream to free buffered chunks. - void result.stream.cancel(); + streams.push(result.stream); } + // Wait for all snapshots to finish writing before revalidating so the + // re-rendered /actionable reads fresh counts. With queueKey === "" the + // jobs all run in parallel, so this waits roughly the time of the + // slowest snapshot, not the sum. + await Promise.all(streams.map(drainStream)); revalidatePath("/actionable"); revalidatePath("/"); return { queued, skipped }; diff --git a/editor/app/jobs/active/page.tsx b/editor/app/jobs/active/page.tsx @@ -22,6 +22,10 @@ export default async function ActiveJobsPage() { const activeJobs: RunningJobsListItem[] = registry .list() .filter((j) => j.status === "running" || j.status === "queued") + .sort((a, b) => { + if (a.status === b.status) return 0; + return a.status === "running" ? -1 : 1; + }) .map((j) => ({ id: j.id, kind: j.kind, diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -25,7 +25,7 @@ export async function generateMetadata(): Promise<Metadata> { type NavLink = { href: string; label: string; - badgeKey?: "jobs" | "changelog"; + badgeKey?: "jobs" | "running" | "changelog"; }; const NAV_LINKS: NavLink[] = [ @@ -33,7 +33,7 @@ const NAV_LINKS: NavLink[] = [ { href: "/channels", label: "Channels" }, { href: "/actionable", label: "Actionable" }, { href: "/jobs", label: "Jobs", badgeKey: "jobs" }, - { href: "/jobs/active", label: "Active", badgeKey: "jobs" }, + { href: "/jobs/active", label: "Active", badgeKey: "running" }, { href: "/build", label: "Build" }, { href: "/deploy", label: "Deploy" }, { href: "/settings", label: "Settings" }, @@ -59,9 +59,11 @@ export default async function RootLayout({ }>) { const settings = getSettings(); const headerLabel = settings.headerTitle || settings.siteTitle; - const activeJobs = getRegistry() - .list() - .filter((j) => j.status === "running" || j.status === "queued").length; + const allJobs = getRegistry().list(); + const activeJobs = allJobs.filter( + (j) => j.status === "running" || j.status === "queued", + ).length; + const runningJobs = allJobs.filter((j) => j.status === "running").length; const latestChangelogDate = readChangelogLatestDate(); return ( <html lang="en" className="h-full antialiased"> @@ -93,8 +95,17 @@ export default async function RootLayout({ /> ); } + const badgeCount = + link.badgeKey === "jobs" + ? activeJobs + : link.badgeKey === "running" + ? runningJobs + : 0; + const badgeNoun = + link.badgeKey === "running" ? "running job" : "active job"; const showBadge = - link.badgeKey === "jobs" && activeJobs > 0; + (link.badgeKey === "jobs" || link.badgeKey === "running") && + badgeCount > 0; return ( <Link key={link.href} @@ -104,10 +115,10 @@ export default async function RootLayout({ <span>{link.label}</span> {showBadge && ( <span - aria-label={`${activeJobs} active job${activeJobs === 1 ? "" : "s"}`} + aria-label={`${badgeCount} ${badgeNoun}${badgeCount === 1 ? "" : "s"}`} className="ml-auto text-xs rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-2 py-0.5 leading-none" > - {activeJobs} + {badgeCount} </span> )} </Link> diff --git a/editor/e2e/jobs-active-order.spec.ts b/editor/e2e/jobs-active-order.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +async function setQueueViaCustom( + page: import("@playwright/test").Page, + actionLabel: string, + value: string, +) { + await page + .getByLabel(`queue for ${actionLabel}`, { exact: true }) + .selectOption({ label: "Custom…" }); + await page + .getByLabel(`custom queue for ${actionLabel}`, { exact: true }) + .fill(value); + await page + .getByRole("button", { name: `save custom queue for ${actionLabel}` }) + .click(); +} + +test("active jobs page sorts channels with running jobs before queued-only channels", async ({ + page, +}) => { + await resetData("two-slow-channels"); + + await page.goto("/channels/slow-a"); + await setQueueViaCustom(page, "Sync", "qShared"); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync output")).toContainText("test-slow", { + timeout: 15_000, + }); + + await page.goto("/channels/slow-b"); + await page + .getByLabel("queue for Sync", { exact: true }) + .selectOption({ label: "qShared" }); + await page.getByRole("button", { name: "Sync" }).click(); + await expect(page.getByLabel("Sync queue status")).toContainText("qShared", { + timeout: 10_000, + }); + + await page.goto("/jobs/active"); + + const sections = page.locator("section[aria-label^='Active jobs for ']"); + await expect(sections).toHaveCount(2); + const labels = await sections.evaluateAll((els) => + els.map((e) => e.getAttribute("aria-label") ?? ""), + ); + expect(labels[0]).toContain("Slow A"); + expect(labels[1]).toContain("Slow B"); + + const slowASection = sections.filter({ hasText: "Slow A" }); + const slowBSection = sections.filter({ hasText: "Slow B" }); + await expect(slowASection.getByText("running", { exact: true })).toBeVisible(); + await expect(slowBSection.getByText("queued", { exact: true })).toBeVisible(); + + // Nav badges: Jobs counts running + queued; Active counts running only. + await expect(page.getByLabel("2 active jobs")).toHaveText("2"); + await expect(page.getByLabel("1 running job")).toHaveText("1"); +});