Fix React errors, add retry countdowns, stats depth, mobile layout, ARM64 nightly CI

- Fix React #418/#423: remove activeIndex effect from SettingsPanel, defer
  applyRootTheme via requestAnimationFrame in AppearanceSettings
- Add retry countdown on failed job rows with 30s refresh tick
- Add attempt_count to Job interface; add job_count to CodecSavings (Rust + frontend)
- Stats page: Total Library Reduction headline, job counts per codec, recharts BarChart
  for jobs-per-day histogram
- Mobile layout: hamburger overlay sidebar with backdrop, hide Updated/priority
  columns on small screens in jobs table
- CI: check-web now runs e2e tests; docker workflow adds nightly schedule
  building linux/amd64,linux/arm64 and tagging :nightly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 10:43:56 -04:00
parent 3518dccac3
commit f689453261
9 changed files with 218 additions and 55 deletions

View File

@@ -1,6 +1,8 @@
name: Docker
on:
schedule:
- cron: '0 2 * * *' # 02:00 UTC nightly, runs against master
pull_request:
paths:
- 'VERSION'
@@ -43,7 +45,7 @@ jobs:
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
if: github.event_name == 'workflow_dispatch'
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
@@ -90,3 +92,23 @@ jobs:
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true
provenance: false
- name: Build and push (nightly)
if: github.event_name == 'schedule'
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.image.outputs.name }}:nightly
cache-from: |
type=gha,scope=docker-amd64
type=gha,scope=docker-arm64
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-amd64
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-arm64
cache-to: |
type=gha,scope=docker-amd64,mode=max
type=gha,scope=docker-arm64,mode=max
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true
provenance: false

View File

@@ -80,6 +80,7 @@ check-rust:
# Frontend checks only
check-web:
cd web && bun install --frozen-lockfile && bun run typecheck && bun run build
cd web-e2e && bun install --frozen-lockfile && bun run test
# ─────────────────────────────────────────
# TESTS

View File

@@ -550,6 +550,7 @@ pub struct EncodeStatsInput {
pub struct CodecSavings {
pub codec: String,
pub bytes_saved: i64,
pub job_count: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -1918,7 +1919,8 @@ impl Db {
let savings_by_codec = sqlx::query(
"SELECT
COALESCE(NULLIF(TRIM(e.output_codec), ''), 'unknown') as codec,
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved,
COUNT(*) as job_count
FROM encode_stats e
JOIN jobs j ON j.id = e.job_id
WHERE e.output_size_bytes IS NOT NULL
@@ -1931,6 +1933,7 @@ impl Db {
.map(|row| CodecSavings {
codec: row.get("codec"),
bytes_saved: row.get("bytes_saved"),
job_count: row.get("job_count"),
})
.collect::<Vec<_>>();

View File

@@ -303,7 +303,10 @@ export default function AppearanceSettings() {
}, []);
useEffect(() => {
applyRootTheme(activeThemeId);
const frame = requestAnimationFrame(() => {
applyRootTheme(activeThemeId);
});
return () => cancelAnimationFrame(frame);
}, [activeThemeId]);
const handleSelect = useCallback(

View File

@@ -258,11 +258,35 @@ interface Job {
progress: number;
created_at: string;
updated_at: string;
attempt_count: number;
vmaf_score?: number;
decision_reason?: string;
encoder?: string;
}
function retryCountdown(job: Job): string | null {
if (job.status !== "failed") return null;
if (!job.attempt_count || job.attempt_count === 0) return null;
const backoffMins =
job.attempt_count === 1 ? 5
: job.attempt_count === 2 ? 15
: job.attempt_count === 3 ? 60
: 360;
const updatedMs = new Date(job.updated_at).getTime();
const retryAtMs = updatedMs + backoffMins * 60 * 1000;
const remainingMs = retryAtMs - Date.now();
if (remainingMs <= 0) return "Retrying soon";
const remainingMins = Math.ceil(remainingMs / 60_000);
if (remainingMins < 60) return `Retrying in ${remainingMins}m`;
const hrs = Math.floor(remainingMins / 60);
const mins = remainingMins % 60;
return mins > 0 ? `Retrying in ${hrs}h ${mins}m` : `Retrying in ${hrs}h`;
}
interface JobMetadata {
duration_secs: number;
codec_name: string;
@@ -346,6 +370,12 @@ export default function JobManager() {
confirmTone?: "danger" | "primary";
onConfirm: () => Promise<void> | void;
} | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick(t => t + 1), 30_000);
return () => window.clearInterval(id);
}, []);
const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status);
@@ -1024,7 +1054,7 @@ export default function JobManager() {
<th className="px-6 py-4">File</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Progress</th>
<th className="px-6 py-4">Updated</th>
<th className="hidden md:table-cell px-6 py-4">Updated</th>
<th className="px-6 py-4 w-14"></th>
</tr>
</thead>
@@ -1070,7 +1100,7 @@ export default function JobManager() {
<span className="text-xs text-helios-slate truncate max-w-[240px]">
{job.input_path}
</span>
<span className="rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
<span className="hidden md:inline rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
P{job.priority}
</span>
</div>
@@ -1080,6 +1110,16 @@ export default function JobManager() {
<motion.div layoutId={`job-status-${job.id}`}>
{getStatusBadge(job.status)}
</motion.div>
{job.status === "failed" && (() => {
// Reference tick so React re-renders countdowns on interval
void tick;
const countdown = retryCountdown(job);
return countdown ? (
<p className="text-[10px] font-mono text-helios-slate mt-0.5">
{countdown}
</p>
) : null;
})()}
</td>
<td className="px-6 py-4">
{["encoding", "analyzing", "remuxing"].includes(job.status) ? (
@@ -1114,7 +1154,7 @@ export default function JobManager() {
)
)}
</td>
<td className="px-6 py-4 text-xs text-helios-slate font-mono">
<td className="hidden md:table-cell px-6 py-4 text-xs text-helios-slate font-mono">
{new Date(job.updated_at).toLocaleString()}
</td>
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>

View File

@@ -5,6 +5,7 @@ import { showToast } from "../lib/toast";
interface CodecSavings {
codec: string;
bytes_saved: number;
job_count: number;
}
interface DailySavings {
@@ -127,6 +128,34 @@ export default function SavingsOverview() {
return (
<div className="space-y-6">
{/* Total Library Reduction */}
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="text-sm font-medium text-helios-slate mb-4">Total Library Reduction</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<div className="text-xs text-helios-slate/70">Original size</div>
<div className="mt-1 font-mono text-2xl font-bold text-helios-ink">
{formatHeroStorage(summary.total_input_bytes)}
</div>
</div>
<div>
<div className="text-xs text-helios-slate/70">Current size</div>
<div className="mt-1 font-mono text-2xl font-bold text-helios-ink">
{formatHeroStorage(summary.total_output_bytes)}
</div>
</div>
<div>
<div className="text-xs text-helios-slate/70">Space recovered</div>
<div className="mt-1 font-mono text-2xl font-bold text-helios-solar">
{formatHeroStorage(summary.total_bytes_saved)}
<span className="ml-2 text-base font-semibold text-helios-slate">
({summary.savings_percent.toFixed(1)}%)
</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
<div className="text-sm font-medium text-helios-slate">Total saved</div>
@@ -190,7 +219,7 @@ export default function SavingsOverview() {
{summary.savings_by_codec.map((entry) => (
<div
key={entry.codec}
className="grid grid-cols-[120px_minmax(0,1fr)_110px] items-center gap-3"
className="grid grid-cols-[120px_minmax(0,1fr)_160px] items-center gap-3"
>
<div className="text-sm font-medium text-helios-ink">
{entry.codec}
@@ -207,7 +236,8 @@ export default function SavingsOverview() {
/>
</div>
<div className="text-right text-sm text-helios-slate">
{formatCompactStorage(entry.bytes_saved)}
{entry.job_count} {entry.job_count === 1 ? "job" : "jobs"},{" "}
{formatCompactStorage(entry.bytes_saved)} saved
</div>
</div>
))}

View File

@@ -32,14 +32,17 @@ export default function SettingsPanel() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const requested = params.get("tab");
if (requested && TABS.some((tab) => tab.id === requested)) {
setActiveTab(requested);
if (requested) {
if (TABS.some((tab) => tab.id === requested)) {
setActiveTab(requested);
} else {
setActiveTab("watch");
}
}
}, []);
const activeIndex = TABS.findIndex(t => t.id === activeTab);
const paginate = (newTabId: string) => {
if (!TABS.some((tab) => tab.id === newTabId)) return;
setActiveTab(newTabId);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
@@ -50,12 +53,6 @@ export default function SettingsPanel() {
const navItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
useEffect(() => {
if (activeIndex < 0) {
setActiveTab("watch");
}
}, [activeIndex]);
useEffect(() => {
const target = navItemRefs.current[activeTab];
if (target) {

View File

@@ -6,6 +6,8 @@ import {
Video,
Terminal,
BarChart3,
Menu,
X,
} from "lucide-react";
import SystemStatus from "./SystemStatus.tsx";
@@ -21,19 +23,49 @@ const navItems = [
];
---
{/* Mobile top bar */}
<div class="lg:hidden flex items-center justify-between px-4 py-3 bg-helios-surface border-b border-helios-line/60">
<a href="/" class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a>
<button
id="sidebar-hamburger"
aria-label="Open navigation"
class="p-2 rounded-md text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
>
<Menu size={22} />
</button>
</div>
{/* Mobile overlay backdrop */}
<div
id="sidebar-backdrop"
class="hidden lg:hidden fixed inset-0 z-40 bg-black/50"
aria-hidden="true"
></div>
{/* Sidebar — hidden on mobile until hamburger opens it */}
<aside
class="w-full lg:w-64 bg-helios-surface border-b lg:border-b-0 lg:border-r border-helios-line/60 flex flex-col lg:flex-col p-3 lg:p-4 gap-3 lg:gap-4"
id="sidebar"
class="hidden lg:flex w-64 bg-helios-surface border-r border-helios-line/60 flex-col p-4 gap-4
fixed lg:static inset-y-0 left-0 z-50 lg:z-auto
transition-transform duration-200 lg:transition-none"
>
<a
href="/"
class="flex items-center px-3 pb-3 lg:pb-4 border-b border-helios-line/40"
class="flex items-center px-3 pb-4 border-b border-helios-line/40"
>
<span class="font-bold text-lg tracking-tight text-helios-ink">
Alchemist
</span>
<button
id="sidebar-close"
aria-label="Close navigation"
class="lg:hidden ml-auto p-1 rounded-md text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
>
<X size={18} />
</button>
</a>
<nav class="flex flex-row lg:flex-col gap-2 flex-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0">
<nav class="flex flex-col gap-2 flex-1">
{
navItems.map(({ href, label, Icon }) => {
const isActive =
@@ -43,7 +75,7 @@ const navItems = [
<a
href={href}
class:list={[
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap shrink-0",
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap",
isActive
? "border-helios-solar bg-helios-solar/10 text-helios-ink font-semibold"
: "text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink",
@@ -57,9 +89,40 @@ const navItems = [
}
</nav>
<div class="mt-auto hidden lg:block">
<div class="mt-3 border-t border-helios-line/30 pt-3">
<div class="mt-auto">
<div class="border-t border-helios-line/30 pt-3">
<SystemStatus client:load />
</div>
</div>
</aside>
<script>
function initSidebar() {
const hamburger = document.getElementById("sidebar-hamburger");
const closeBtn = document.getElementById("sidebar-close");
const backdrop = document.getElementById("sidebar-backdrop");
const sidebar = document.getElementById("sidebar");
if (!hamburger || !closeBtn || !backdrop || !sidebar) return;
function openSidebar() {
sidebar!.classList.remove("hidden");
backdrop!.classList.remove("hidden");
document.body.style.overflow = "hidden";
}
function closeSidebar() {
sidebar!.classList.add("hidden");
backdrop!.classList.add("hidden");
document.body.style.overflow = "";
}
hamburger.addEventListener("click", openSidebar);
closeBtn.addEventListener("click", closeSidebar);
backdrop.addEventListener("click", closeSidebar);
}
// Run on initial load and after Astro view transitions
initSidebar();
document.addEventListener("astro:after-swap", initSidebar);
</script>

View File

@@ -11,6 +11,7 @@ import {
Timer,
type LucideIcon,
} from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import { apiJson, isApiError } from "../lib/api";
interface AggregatedStats {
@@ -127,9 +128,6 @@ export default function StatsCharts() {
? (detailedStats.reduce((sum, s) => sum + s.avg_bitrate_kbps, 0) / detailedStats.length).toFixed(0)
: "N/A";
// Find max for bar chart scaling
const maxDailyJobs = Math.max(...dailyStats.map(d => d.jobs_completed), 1);
interface StatCardProps {
icon: LucideIcon;
label: string;
@@ -217,41 +215,47 @@ export default function StatsCharts() {
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40">
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
<BarChart3 size={20} className="text-blue-500" />
Daily Activity (Last 30 Days)
Jobs per Day (Last 30 Days)
</h3>
{dailyStats.length > 0 ? (
<div className="h-48 flex items-end gap-1">
{dailyStats.map((day, i) => {
const height = (day.jobs_completed / maxDailyJobs) * 100;
return (
<div
key={i}
className="flex-1 flex flex-col items-center group"
>
<div
className="w-full bg-gradient-to-t from-blue-500 to-blue-400 rounded-t transition-all hover:from-blue-600 hover:to-blue-500 cursor-pointer relative"
style={{ height: `${Math.max(height, 4)}%` }}
title={`${formatDate(day.date)}: ${day.jobs_completed} jobs, ${formatBytes(day.bytes_saved)} saved`}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-helios-ink text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap pointer-events-none z-10">
{day.jobs_completed} jobs
</div>
</div>
</div>
);
})}
</div>
<ResponsiveContainer width="100%" height={192}>
<BarChart
data={dailyStats.map(d => ({
label: formatDate(d.date),
jobs: d.jobs_completed,
}))}
margin={{ top: 4, right: 4, left: -24, bottom: 4 }}
>
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: "rgb(var(--text-muted, 160 160 160))" }}
interval="preserveStartEnd"
tickLine={false}
axisLine={false}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 10, fill: "rgb(var(--text-muted, 160 160 160))" }}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(value: number) => [value, "Jobs"]}
contentStyle={{
background: "rgb(var(--bg-panel, 30 30 30))",
border: "1px solid rgb(var(--border, 60 60 60))",
borderRadius: "6px",
fontSize: "12px",
}}
/>
<Bar dataKey="jobs" fill="rgb(59 130 246)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-helios-slate">
No daily data available
</div>
)}
{dailyStats.length > 0 && (
<div className="flex justify-between text-xs text-helios-slate mt-2">
<span>{formatDate(dailyStats[0]?.date)}</span>
<span>{formatDate(dailyStats[dailyStats.length - 1]?.date)}</span>
</div>
)}
</div>
{/* Space Efficiency */}