mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
Fix engine controls and job detail rendering
This commit is contained in:
94
web-e2e/tests/dashboard-ui.spec.ts
Normal file
94
web-e2e/tests/dashboard-ui.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { fulfillJson, mockDashboardData, mockEngineStatus } from "./helpers";
|
||||
|
||||
test.use({ storageState: undefined });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockEngineStatus(page);
|
||||
await mockDashboardData(page);
|
||||
await page.route("**/api/stats/daily", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
await page.route("**/api/settings/watch-dirs**", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
await page.route("**/api/profiles/presets", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
await page.route("**/api/profiles", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
});
|
||||
|
||||
test("Last 7 Days panel is shown on dashboard", async ({ page }) => {
|
||||
await page.unroute("**/api/stats/daily");
|
||||
await page.route("**/api/stats/daily", async (route) => {
|
||||
await fulfillJson(route, 200, [
|
||||
{
|
||||
date: "2026-03-22",
|
||||
jobs_completed: 5,
|
||||
bytes_saved: 1_073_741_824,
|
||||
},
|
||||
{
|
||||
date: "2026-03-23",
|
||||
jobs_completed: 3,
|
||||
bytes_saved: 536_870_912,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByText("Last 7 Days")).toBeVisible();
|
||||
await expect(page.getByText("Space recovered")).toBeVisible();
|
||||
await expect(page.getByText("Jobs completed")).toBeVisible();
|
||||
});
|
||||
|
||||
test("ENGINE PAUSED banner is shown when engine is paused and mentions auto-analysis", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByText("ENGINE PAUSED")).toBeVisible();
|
||||
await expect(page.getByText(/Analysis runs automatically/i)).toBeVisible();
|
||||
await expect(page.getByText(/won't start encoding until/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("ENGINE PAUSED banner is hidden when engine is running", async ({ page }) => {
|
||||
await page.unroute("**/api/engine/status");
|
||||
await page.unroute("**/api/engine/mode");
|
||||
await mockEngineStatus(page, {
|
||||
status: "running",
|
||||
manual_paused: false,
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByText("ENGINE PAUSED")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("About modal opens and does not contain Al badge", async ({ page }) => {
|
||||
await page.route("**/api/system/info", async (route) => {
|
||||
await fulfillJson(route, 200, {
|
||||
version: "0.3.0",
|
||||
os_version: "macos aarch64",
|
||||
is_docker: false,
|
||||
telemetry_enabled: false,
|
||||
ffmpeg_version: "N-12345",
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "About" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Alchemist" })).toBeVisible();
|
||||
await expect(page.getByText("v0.3.0")).toBeVisible();
|
||||
await expect(page.getByText(/^Al$/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Settings page does not show Setup & Runtime Controls banner", async ({ page }) => {
|
||||
await page.goto("/settings");
|
||||
|
||||
await expect(page.getByText("Setup & Runtime Controls")).not.toBeVisible();
|
||||
});
|
||||
@@ -1,21 +1,23 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createEngineStatus, fulfillJson, mockDashboardData } from "./helpers";
|
||||
import {
|
||||
createEngineMode,
|
||||
createEngineStatus,
|
||||
fulfillJson,
|
||||
mockDashboardData,
|
||||
} from "./helpers";
|
||||
|
||||
test.use({ storageState: undefined });
|
||||
|
||||
test("start, stop, cancel stop, and pause update dashboard engine controls", async ({
|
||||
test("engine cycle: Start transitions to Running, Stop transitions to Stopping", async ({
|
||||
page,
|
||||
}) => {
|
||||
let engineStatus = createEngineStatus({
|
||||
status: "paused",
|
||||
manual_paused: true,
|
||||
scheduler_paused: false,
|
||||
draining: false,
|
||||
});
|
||||
let resumeCalls = 0;
|
||||
let pauseCalls = 0;
|
||||
let drainCalls = 0;
|
||||
let stopDrainCalls = 0;
|
||||
|
||||
await mockDashboardData(page);
|
||||
|
||||
@@ -23,80 +25,46 @@ test("start, stop, cancel stop, and pause update dashboard engine controls", asy
|
||||
await fulfillJson(route, 200, engineStatus);
|
||||
});
|
||||
await page.route("**/api/engine/mode", async (route) => {
|
||||
await fulfillJson(route, 200, {
|
||||
mode: engineStatus.mode,
|
||||
is_manual_override: engineStatus.is_manual_override,
|
||||
concurrent_limit: engineStatus.concurrent_limit,
|
||||
cpu_count: 8,
|
||||
computed_limits: {
|
||||
background: 1,
|
||||
balanced: 4,
|
||||
throughput: 4,
|
||||
},
|
||||
});
|
||||
await fulfillJson(route, 200, createEngineMode());
|
||||
});
|
||||
await page.route("**/api/engine/resume", async (route) => {
|
||||
resumeCalls += 1;
|
||||
engineStatus = createEngineStatus({
|
||||
status: "running",
|
||||
manual_paused: false,
|
||||
scheduler_paused: false,
|
||||
draining: false,
|
||||
});
|
||||
await fulfillJson(route, 200, { status: "running" });
|
||||
});
|
||||
await page.route("**/api/engine/pause", async (route) => {
|
||||
pauseCalls += 1;
|
||||
engineStatus = createEngineStatus({
|
||||
status: "paused",
|
||||
manual_paused: true,
|
||||
scheduler_paused: false,
|
||||
draining: false,
|
||||
});
|
||||
await fulfillJson(route, 200, { status: "paused" });
|
||||
});
|
||||
await page.route("**/api/engine/drain", async (route) => {
|
||||
drainCalls += 1;
|
||||
engineStatus = createEngineStatus({
|
||||
status: "draining",
|
||||
manual_paused: false,
|
||||
scheduler_paused: false,
|
||||
draining: true,
|
||||
});
|
||||
await fulfillJson(route, 200, { status: "draining" });
|
||||
});
|
||||
await page.route("**/api/engine/stop-drain", async (route) => {
|
||||
stopDrainCalls += 1;
|
||||
engineStatus = createEngineStatus({
|
||||
status: "running",
|
||||
manual_paused: false,
|
||||
scheduler_paused: false,
|
||||
draining: false,
|
||||
});
|
||||
await fulfillJson(route, 200, { status: "running" });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByText("Paused", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Start" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Pause" })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Cancel Stop" })).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Start" }).click();
|
||||
await expect.poll(() => resumeCalls).toBe(1);
|
||||
await expect(page.getByText("Running", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Pause" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Pause" })).not.toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Stop" }).click();
|
||||
await expect.poll(() => drainCalls).toBe(1);
|
||||
await expect(page.getByText("Draining", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Cancel Stop" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Cancel Stop" }).click();
|
||||
await expect.poll(() => stopDrainCalls).toBe(1);
|
||||
await expect(page.getByText("Running", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Pause" }).click();
|
||||
await expect.poll(() => pauseCalls).toBe(1);
|
||||
await expect(page.getByText("Paused", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Stopping", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Draining", { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Cancel Stop" })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Stopping/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("scheduler pause note is shown when pause comes from automation", async ({ page }) => {
|
||||
@@ -113,17 +81,7 @@ test("scheduler pause note is shown when pause comes from automation", async ({
|
||||
);
|
||||
});
|
||||
await page.route("**/api/engine/mode", async (route) => {
|
||||
await fulfillJson(route, 200, {
|
||||
mode: "balanced",
|
||||
is_manual_override: false,
|
||||
concurrent_limit: 2,
|
||||
cpu_count: 8,
|
||||
computed_limits: {
|
||||
background: 1,
|
||||
balanced: 4,
|
||||
throughput: 4,
|
||||
},
|
||||
});
|
||||
await fulfillJson(route, 200, createEngineMode());
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
@@ -137,17 +95,7 @@ test("failed engine transitions surface an error toast", async ({ page }) => {
|
||||
await fulfillJson(route, 200, createEngineStatus());
|
||||
});
|
||||
await page.route("**/api/engine/mode", async (route) => {
|
||||
await fulfillJson(route, 200, {
|
||||
mode: "balanced",
|
||||
is_manual_override: false,
|
||||
concurrent_limit: 2,
|
||||
cpu_count: 8,
|
||||
computed_limits: {
|
||||
background: 1,
|
||||
balanced: 4,
|
||||
throughput: 4,
|
||||
},
|
||||
});
|
||||
await fulfillJson(route, 200, createEngineMode());
|
||||
});
|
||||
await page.route("**/api/engine/resume", async (route) => {
|
||||
await fulfillJson(route, 500, { message: "resume failed" });
|
||||
|
||||
@@ -151,7 +151,7 @@ test("search requests are debounced and failed job details show summary and logs
|
||||
await page.getByTitle("/media/failed.mkv").click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("What went wrong")).toBeVisible();
|
||||
await expect(page.getByText("Failure Reason")).toBeVisible();
|
||||
await expect(page.getByText("Unknown encoder 'missing_encoder'").first()).toBeVisible();
|
||||
await page.getByText(/Show FFmpeg output \(2 lines\)/).click();
|
||||
await expect(page.getByText("frame=5 fps=10")).toBeVisible();
|
||||
@@ -267,3 +267,141 @@ test("detail modal delete action removes the job and closes the modal", async ({
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByTitle("/media/completed.mkv")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("queued job with no metadata shows waiting for analysis placeholder", async ({ page }) => {
|
||||
await page.route("**/api/jobs/table**", async (route) => {
|
||||
await fulfillJson(route, 200, [queuedJob]);
|
||||
});
|
||||
await mockJobDetails(page, {
|
||||
1: {
|
||||
job: queuedJob,
|
||||
job_logs: [],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/jobs");
|
||||
await page.getByTitle("/media/queued.mkv").click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Waiting for analysis")).toBeVisible();
|
||||
await expect(page.getByText("Unknown bit depth")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("analyzed queued job shows real metadata in detail modal", async ({ page }) => {
|
||||
const analyzedJob: JobFixture = {
|
||||
...queuedJob,
|
||||
id: 10,
|
||||
input_path: "/media/analyzed.mkv",
|
||||
output_path: "/output/analyzed-av1.mkv",
|
||||
};
|
||||
|
||||
await page.route("**/api/jobs/table**", async (route) => {
|
||||
await fulfillJson(route, 200, [analyzedJob]);
|
||||
});
|
||||
await mockJobDetails(page, {
|
||||
10: {
|
||||
job: analyzedJob,
|
||||
metadata: {
|
||||
duration_secs: 3600,
|
||||
codec_name: "h264",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bit_depth: 8,
|
||||
size_bytes: 4_000_000_000,
|
||||
video_bitrate_bps: 8_000_000,
|
||||
container_bitrate_bps: 8_200_000,
|
||||
fps: 23.976,
|
||||
container: "mkv",
|
||||
audio_codec: "aac",
|
||||
audio_channels: 2,
|
||||
dynamic_range: "sdr",
|
||||
},
|
||||
job_logs: [],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/jobs");
|
||||
await page.getByTitle("/media/analyzed.mkv").click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("h264")).toBeVisible();
|
||||
await expect(page.getByText("1920x1080")).toBeVisible();
|
||||
await expect(page.getByText("Waiting for analysis")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("skipped job shows humanized skip reason with measured values", async ({ page }) => {
|
||||
const skippedJob: JobFixture = {
|
||||
id: 20,
|
||||
input_path: "/media/already-small.mkv",
|
||||
output_path: "/output/already-small-av1.mkv",
|
||||
status: "skipped",
|
||||
priority: 0,
|
||||
progress: 0,
|
||||
decision_reason: "bpp_below_threshold|bpp=0.043,threshold=0.050",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
await page.route("**/api/jobs/table**", async (route) => {
|
||||
await fulfillJson(route, 200, [skippedJob]);
|
||||
});
|
||||
await mockJobDetails(page, {
|
||||
20: {
|
||||
job: skippedJob,
|
||||
metadata: {
|
||||
duration_secs: 1800,
|
||||
codec_name: "hevc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bit_depth: 10,
|
||||
size_bytes: 2_000_000_000,
|
||||
fps: 24,
|
||||
container: "mkv",
|
||||
dynamic_range: "sdr",
|
||||
},
|
||||
job_logs: [],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/jobs");
|
||||
await page.getByTitle("/media/already-small.mkv").click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Already efficiently compressed")).toBeVisible();
|
||||
await expect(page.getByText("0.043", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("0.050", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("bpp_below_threshold|")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("failed job with no failure summary shows fallback message", async ({ page }) => {
|
||||
const mysteryFailJob: JobFixture = {
|
||||
id: 30,
|
||||
input_path: "/media/mystery.mkv",
|
||||
output_path: "/output/mystery-av1.mkv",
|
||||
status: "failed",
|
||||
priority: 0,
|
||||
progress: 0,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
await page.route("**/api/jobs/table**", async (route) => {
|
||||
await fulfillJson(route, 200, [mysteryFailJob]);
|
||||
});
|
||||
await mockJobDetails(page, {
|
||||
30: {
|
||||
job: mysteryFailJob,
|
||||
metadata: undefined,
|
||||
encode_stats: undefined,
|
||||
job_logs: [],
|
||||
job_failure_summary: null,
|
||||
} as unknown as JobDetailFixture,
|
||||
});
|
||||
|
||||
await page.goto("/jobs");
|
||||
await page.getByTitle("/media/mystery.mkv").click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByText("Failure Reason")).toBeVisible();
|
||||
await expect(page.getByText(/No error details captured/)).toBeVisible();
|
||||
});
|
||||
|
||||
167
web-e2e/tests/library-intake.spec.ts
Normal file
167
web-e2e/tests/library-intake.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
createSettingsBundle,
|
||||
fulfillEmpty,
|
||||
fulfillJson,
|
||||
mockEngineStatus,
|
||||
mockSettingsBundle,
|
||||
} from "./helpers";
|
||||
|
||||
test.use({ storageState: undefined });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockEngineStatus(page);
|
||||
await page.route("**/api/library/profiles", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
await page.route("**/api/profiles/presets", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
await page.route("**/api/profiles", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
await page.route("**/api/scan/status", async (route) => {
|
||||
await fulfillJson(route, 200, {
|
||||
is_running: false,
|
||||
files_found: 0,
|
||||
current_folder: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("library intake shows unified folder list with no Library Directories or Watch Folders headings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockSettingsBundle(page, {
|
||||
scanner: {
|
||||
directories: ["/media/movies"],
|
||||
watch_enabled: true,
|
||||
extra_watch_dirs: [],
|
||||
},
|
||||
});
|
||||
await page.route("**/api/settings/watch-dirs**", async (route) => {
|
||||
await fulfillJson(route, 200, [
|
||||
{
|
||||
id: 1,
|
||||
path: "/media/tv",
|
||||
is_recursive: true,
|
||||
profile_id: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await page.goto("/settings?tab=watch");
|
||||
|
||||
await expect(page.getByText("/media/movies")).toBeVisible();
|
||||
await expect(page.getByText("/media/tv")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Library Directories" })).not.toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Watch Folders" })).not.toBeVisible();
|
||||
await expect(page.getByText(/watch subdirectories recursively/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Scan Now button is present on Library & Intake page", async ({ page }) => {
|
||||
await mockSettingsBundle(page);
|
||||
await page.route("**/api/settings/watch-dirs**", async (route) => {
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
|
||||
await page.goto("/settings?tab=watch");
|
||||
|
||||
await expect(page.getByRole("button", { name: /scan now/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("adding a folder via text input calls bundle and watch-dirs APIs", async ({ page }) => {
|
||||
let bundlePutCalled = false;
|
||||
let watchDirsPostCalled = false;
|
||||
|
||||
await page.route("**/api/settings/bundle", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
bundlePutCalled = true;
|
||||
await fulfillJson(route, 200, {});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(
|
||||
route,
|
||||
200,
|
||||
createSettingsBundle({
|
||||
scanner: {
|
||||
directories: [],
|
||||
watch_enabled: true,
|
||||
extra_watch_dirs: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.route("**/api/settings/watch-dirs**", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
watchDirsPostCalled = true;
|
||||
await fulfillJson(route, 201, {
|
||||
id: 99,
|
||||
path: "/media/new",
|
||||
is_recursive: true,
|
||||
profile_id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, 200, []);
|
||||
});
|
||||
|
||||
await page.goto("/settings?tab=watch");
|
||||
await page.getByPlaceholder(/path/i).fill("/media/new");
|
||||
await page.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect.poll(() => bundlePutCalled).toBe(true);
|
||||
await expect.poll(() => watchDirsPostCalled).toBe(true);
|
||||
});
|
||||
|
||||
test("deleting a folder calls bundle PUT and watch-dirs DELETE", async ({ page }) => {
|
||||
let bundlePutCalled = false;
|
||||
let watchDirDeleteCalled = false;
|
||||
|
||||
await page.route("**/api/settings/bundle", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
bundlePutCalled = true;
|
||||
await fulfillJson(route, 200, {});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(
|
||||
route,
|
||||
200,
|
||||
createSettingsBundle({
|
||||
scanner: {
|
||||
directories: ["/media/movies"],
|
||||
watch_enabled: true,
|
||||
extra_watch_dirs: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.route("**/api/settings/watch-dirs**", async (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
watchDirDeleteCalled = true;
|
||||
await fulfillEmpty(route, 204);
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, 200, [
|
||||
{
|
||||
id: 1,
|
||||
path: "/media/movies",
|
||||
is_recursive: true,
|
||||
profile_id: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await page.goto("/settings?tab=watch");
|
||||
await expect(page.getByText("/media/movies")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Remove /media/movies" }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Remove" }).click();
|
||||
|
||||
await expect.poll(() => bundlePutCalled).toBe(true);
|
||||
await expect.poll(() => watchDirDeleteCalled).toBe(true);
|
||||
});
|
||||
@@ -216,7 +216,7 @@ export default function Dashboard() {
|
||||
{/* Stat row — compact horizontal strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Active Jobs" value={stats.active} icon={Zap} colorClass="text-helios-solar" />
|
||||
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} colorClass="text-emerald-500" />
|
||||
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} colorClass="text-status-success" />
|
||||
<StatCard label="Failed" value={stats.failed} icon={AlertCircle} colorClass="text-status-error" />
|
||||
<StatCard label="Total Processed" value={stats.total} icon={Database} colorClass="text-helios-solar" />
|
||||
</div>
|
||||
@@ -259,7 +259,7 @@ export default function Dashboard() {
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
s === "completed"
|
||||
? "bg-emerald-500"
|
||||
? "bg-status-success"
|
||||
: s === "failed"
|
||||
? "bg-status-error"
|
||||
: s === "encoding" || s === "analyzing"
|
||||
|
||||
@@ -38,6 +38,8 @@ export default function HeaderActions() {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const status = engineStatus?.status ?? "paused";
|
||||
|
||||
const refreshEngineStatus = async () => {
|
||||
const data = await apiJson<EngineStatus>("/api/engine/status");
|
||||
setEngineStatus(data);
|
||||
@@ -83,6 +85,17 @@ export default function HeaderActions() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fast poll during draining state for responsive UI
|
||||
useEffect(() => {
|
||||
if (status !== "draining") return;
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
void refreshEngineStatus();
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(id);
|
||||
}, [status]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
@@ -128,8 +141,6 @@ export default function HeaderActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const status = engineStatus?.status ?? "paused";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Search, RefreshCw, Trash2, Ban,
|
||||
Clock, X, Info, Activity, Database, Zap, Maximize2, MoreHorizontal, ArrowDown, ArrowUp, AlertCircle
|
||||
@@ -374,6 +375,72 @@ export default function JobManager() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
let cancelled = false;
|
||||
let reconnectTimeout: number | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
eventSource?.close();
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("status", (e) => {
|
||||
try {
|
||||
const { job_id, status } = JSON.parse(e.data) as {
|
||||
job_id: number;
|
||||
status: string;
|
||||
};
|
||||
setJobs((prev) =>
|
||||
prev.map((job) =>
|
||||
job.id === job_id ? { ...job, status } : job
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("progress", (e) => {
|
||||
try {
|
||||
const { job_id, percentage } = JSON.parse(e.data) as {
|
||||
job_id: number;
|
||||
percentage: number;
|
||||
};
|
||||
setJobs((prev) =>
|
||||
prev.map((job) =>
|
||||
job.id === job_id ? { ...job, progress: percentage } : job
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener("decision", () => {
|
||||
// Re-fetch full job list when decisions are made
|
||||
void fetchJobsRef.current();
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource?.close();
|
||||
if (!cancelled) {
|
||||
reconnectTimeout = window.setTimeout(connect, 3000);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
eventSource?.close();
|
||||
if (reconnectTimeout !== null) {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuJobId) return;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
@@ -1027,18 +1094,19 @@ export default function JobManager() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Detail Overlay */}
|
||||
<AnimatePresence>
|
||||
{focusedJob && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setFocusedJob(null)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[101]">
|
||||
{/* Detail Overlay - rendered via portal to escape layout constraints */}
|
||||
{typeof document !== "undefined" && createPortal(
|
||||
<AnimatePresence>
|
||||
{focusedJob && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setFocusedJob(null)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[101]">
|
||||
<motion.div
|
||||
key="modal-content"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
@@ -1396,7 +1464,9 @@ export default function JobManager() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmState !== null}
|
||||
|
||||
@@ -5,7 +5,6 @@ import LibraryStep from "./setup/LibraryStep";
|
||||
import ProcessingStep from "./setup/ProcessingStep";
|
||||
import ReviewStep from "./setup/ReviewStep";
|
||||
import RuntimeStep from "./setup/RuntimeStep";
|
||||
import ScanStep from "./setup/ScanStep";
|
||||
import SetupFrame from "./setup/SetupFrame";
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_DRAFT,
|
||||
@@ -41,7 +40,6 @@ export default function SetupWizard() {
|
||||
const [notificationDraft, setNotificationDraft] = useState<NotificationTargetConfig>(DEFAULT_NOTIFICATION_DRAFT);
|
||||
const [recommendations, setRecommendations] = useState<FsRecommendation[]>([]);
|
||||
const [preview, setPreview] = useState<FsPreviewResponse | null>(null);
|
||||
const [scanRunId, setScanRunId] = useState(0);
|
||||
const validatorRef = useRef<StepValidator>(async () => null);
|
||||
|
||||
const registerValidator = useCallback((validator: StepValidator) => {
|
||||
@@ -103,8 +101,7 @@ export default function SetupWizard() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "setup_complete", value: "true" }),
|
||||
}).catch(() => undefined);
|
||||
setStep(6);
|
||||
setScanRunId((current) => current + 1);
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
let message = "Failed to save setup configuration.";
|
||||
if (isApiError(err)) {
|
||||
@@ -207,8 +204,6 @@ export default function SetupWizard() {
|
||||
);
|
||||
case 5:
|
||||
return <ReviewStep setupSummary={setupSummary} settings={settings} preview={preview} error={null} />;
|
||||
case 6:
|
||||
return <ScanStep runId={scanRunId} onBackToReview={() => setStep(5)} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -359,10 +359,6 @@ export default function WatchFolders() {
|
||||
<div className="space-y-6" aria-live="polite">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold text-helios-ink">
|
||||
<FolderOpen size={20} className="text-helios-solar" />
|
||||
Media Folders
|
||||
</h2>
|
||||
<p className="text-sm text-helios-slate">
|
||||
Folders Alchemist scans and watches for new media.
|
||||
</p>
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function LibraryStep({
|
||||
: "rounded-lg px-2 py-1 transition-colors hover:bg-helios-surface-soft hover:text-helios-ink"
|
||||
}
|
||||
>
|
||||
{crumb.name}
|
||||
{crumb.name.replace(/^\//, "")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -316,7 +316,7 @@ export default function LibraryStep({
|
||||
..
|
||||
</span>
|
||||
<span className="block truncate text-xs text-helios-slate">
|
||||
Go up to {parentBreadcrumb.name}
|
||||
Go up to {parentBreadcrumb.name.replace(/^\//, "")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -369,7 +369,7 @@ export default function LibraryStep({
|
||||
disabled={!currentBrowsePath}
|
||||
className="shrink-0 rounded-lg bg-helios-solar px-4 py-2 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add {currentBrowseName}
|
||||
Add this folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user