Add UI regression coverage for telemetry and hardware settings

This commit is contained in:
2026-03-25 11:10:44 -04:00
parent d5df90db0f
commit 0cd401e03c
11 changed files with 354 additions and 104 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Cpu, Zap, HardDrive, CheckCircle2, AlertCircle, Save, XCircle } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Cpu, Zap, HardDrive, CheckCircle2, AlertCircle, XCircle } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api";
import { showToast } from "../lib/toast";
@@ -44,6 +44,8 @@ export default function HardwareSettings() {
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [draftDevicePath, setDraftDevicePath] = useState("");
const [devicePathDirty, setDevicePathDirty] = useState(false);
const hardwareSettingsRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
void Promise.all([fetchHardware(), fetchSettings(), fetchProbeLog()]).finally(() => setLoading(false));
@@ -63,7 +65,9 @@ export default function HardwareSettings() {
try {
const data = await apiJson<HardwareSettings>("/api/settings/hardware");
setSettings(data);
setDraftDevicePath(data.device_path ?? "");
if (!devicePathDirty) {
setDraftDevicePath(data.device_path ?? "");
}
} catch (err) {
if (!error) {
setError(isApiError(err) ? err.message : "Failed to fetch hardware settings.");
@@ -80,8 +84,14 @@ export default function HardwareSettings() {
}
};
const persistSettings = async (nextSettings: HardwareSettings, message: string) => {
const persistSettings = async (
previousSettings: HardwareSettings,
nextSettings: HardwareSettings,
message: string,
syncDevicePath: boolean,
) => {
setSaving(true);
setSettings(nextSettings);
try {
await apiAction("/api/settings/hardware", {
method: "POST",
@@ -89,10 +99,15 @@ export default function HardwareSettings() {
body: JSON.stringify(nextSettings),
});
setError("");
await Promise.all([fetchHardware(), fetchSettings(), fetchProbeLog()]);
if (syncDevicePath) {
setDraftDevicePath(nextSettings.device_path ?? "");
setDevicePathDirty(false);
}
await Promise.all([fetchHardware(), fetchProbeLog()]);
showToast({ kind: "success", title: "Hardware", message });
} catch (err) {
const errorMessage = isApiError(err) ? err.message : "Failed to update hardware settings";
setSettings(previousSettings);
setError(errorMessage);
showToast({ kind: "error", title: "Hardware", message: errorMessage });
} finally {
@@ -100,16 +115,38 @@ export default function HardwareSettings() {
}
};
const updateCpuEncoding = async (enabled: boolean) => {
const normalizedDraftDevicePath = () => draftDevicePath.trim() || null;
const saveImmediateSettings = async (patch: Partial<HardwareSettings>) => {
if (!settings) return;
await persistSettings({ ...settings, allow_cpu_encoding: enabled }, "Hardware settings saved.");
const previousSettings = settings;
const shouldSyncDevicePath = devicePathDirty;
const nextSettings: HardwareSettings = {
...settings,
...patch,
device_path: shouldSyncDevicePath ? normalizedDraftDevicePath() : settings.device_path,
};
await persistSettings(
previousSettings,
nextSettings,
"Hardware settings saved.",
shouldSyncDevicePath,
);
};
const saveAllSettings = async () => {
const commitDevicePath = async () => {
if (!settings) return;
const nextDevicePath = normalizedDraftDevicePath();
if (nextDevicePath === settings.device_path) {
setDraftDevicePath(settings.device_path ?? "");
setDevicePathDirty(false);
return;
}
await persistSettings(
{ ...settings, device_path: draftDevicePath.trim() || null },
settings,
{ ...settings, device_path: nextDevicePath },
"Hardware settings saved.",
true,
);
};
@@ -168,6 +205,14 @@ export default function HardwareSettings() {
const shouldShowProbeLog = vendor === "cpu" || failedProbeEntries.length > 0;
const intelVaapiDetected = vendor === "intel" && (info.backends ?? []).some((backend) => backend.kind.toLowerCase() === "vaapi");
const handleHardwareSettingsBlur = (event: React.FocusEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && hardwareSettingsRef.current?.contains(nextTarget)) {
return;
}
void commitDevicePath();
};
return (
<div className="flex flex-col gap-6" aria-live="polite">
<div className="flex items-center justify-between pb-2 border-b border-helios-line/10">
@@ -321,7 +366,11 @@ export default function HardwareSettings() {
)}
{settings && (
<div className="bg-helios-surface border border-helios-line/30 rounded-lg p-5 shadow-sm space-y-5">
<div
ref={hardwareSettingsRef}
onBlurCapture={handleHardwareSettingsBlur}
className="bg-helios-surface border border-helios-line/30 rounded-lg p-5 shadow-sm space-y-5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-500">
@@ -334,22 +383,26 @@ export default function HardwareSettings() {
</p>
</div>
</div>
<button
onClick={() => void updateCpuEncoding(!settings.allow_cpu_encoding)}
disabled={saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${settings.allow_cpu_encoding ? "bg-emerald-500" : "bg-helios-line/50"} ${saving ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${settings.allow_cpu_encoding ? "translate-x-6" : "translate-x-1"}`} />
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
aria-label="Allow CPU Encoding"
checked={settings.allow_cpu_encoding}
onChange={(e) => void saveImmediateSettings({ allow_cpu_encoding: e.target.checked })}
disabled={saving}
className="sr-only peer"
/>
<div className="w-11 h-6 rounded-full bg-helios-line/20 peer-focus:outline-none after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:bg-white after:content-[''] after:transition-all peer-checked:after:translate-x-full peer-checked:bg-helios-solar peer-disabled:cursor-not-allowed peer-disabled:opacity-60"></div>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-helios-line/10 pt-5">
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wide text-helios-slate">Preferred Vendor</label>
<label htmlFor="hardware-preferred-vendor" className="text-xs font-medium uppercase tracking-wide text-helios-slate">Preferred Vendor</label>
<select
id="hardware-preferred-vendor"
value={settings.preferred_vendor ?? ""}
onChange={(e) => setSettings({
...settings,
onChange={(e) => void saveImmediateSettings({
preferred_vendor: e.target.value || null,
})}
className="w-full rounded-xl border border-helios-line/30 bg-helios-surface px-4 py-3 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
@@ -364,10 +417,11 @@ export default function HardwareSettings() {
</div>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wide text-helios-slate">CPU Preset</label>
<label htmlFor="hardware-cpu-preset" className="text-xs font-medium uppercase tracking-wide text-helios-slate">CPU Preset</label>
<select
id="hardware-cpu-preset"
value={settings.cpu_preset}
onChange={(e) => setSettings({ ...settings, cpu_preset: e.target.value })}
onChange={(e) => void saveImmediateSettings({ cpu_preset: e.target.value })}
className="w-full rounded-xl border border-helios-line/30 bg-helios-surface px-4 py-3 text-helios-ink focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
>
<option value="slow">Slow</option>
@@ -386,11 +440,13 @@ export default function HardwareSettings() {
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
aria-label="Allow CPU Fallback"
checked={settings.allow_cpu_fallback}
onChange={(e) => setSettings({ ...settings, allow_cpu_fallback: e.target.checked })}
onChange={(e) => void saveImmediateSettings({ allow_cpu_fallback: e.target.checked })}
disabled={saving}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-helios-line/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-helios-solar"></div>
<div className="w-11 h-6 rounded-full bg-helios-line/20 peer-focus:outline-none after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:bg-white after:content-[''] after:transition-all peer-checked:after:translate-x-full peer-checked:bg-helios-solar peer-disabled:cursor-not-allowed peer-disabled:opacity-60"></div>
</label>
</div>
@@ -398,25 +454,30 @@ export default function HardwareSettings() {
<div>
<h4 className="text-sm font-bold text-helios-ink uppercase tracking-wider">Explicit Device Path</h4>
<p className="text-[10px] text-helios-slate font-bold mt-1">
Pin Linux QSV or VAAPI detection to a specific render node. Leave blank to auto-detect.
Optional Linux only. Pin QSV or VAAPI detection to a specific render node, or leave blank to auto-detect.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-col gap-2">
<input
aria-label="Explicit Device Path"
type="text"
value={draftDevicePath}
onChange={(e) => setDraftDevicePath(e.target.value)}
placeholder="/dev/dri/renderD128"
onChange={(e) => {
setDraftDevicePath(e.target.value);
setDevicePathDirty(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void commitDevicePath();
}
}}
placeholder="Optional — Linux only (e.g. /dev/dri/renderD128)"
className="flex-1 bg-helios-surface-soft border border-helios-line/30 rounded-xl px-4 py-3 text-helios-ink font-mono text-sm focus:border-helios-solar focus:ring-1 focus:ring-helios-solar outline-none transition-all"
/>
<button
onClick={() => void saveAllSettings()}
disabled={saving}
className="flex items-center justify-center gap-2 bg-helios-solar text-helios-main font-bold px-5 py-3 rounded-md hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Apply"}
</button>
<p className="text-[10px] text-helios-slate">
Saves on blur or Enter. Other hardware changes will also carry the current device-path draft if you tab or click away.
</p>
</div>
</div>
</div>

View File

@@ -66,10 +66,6 @@ export default function SetupWizard() {
}, 100);
}, []);
useEffect(() => {
validatorRef.current = async () => null;
}, [step]);
useEffect(() => {
const loadBootstrap = async () => {
try {
@@ -156,6 +152,8 @@ export default function SetupWizard() {
[preview, settings.notifications.targets.length, settings.schedule.windows.length, settings.scanner.directories.length]
);
validatorRef.current = async () => null;
const currentStep = (() => {
switch (step) {
case 1:
@@ -163,10 +161,8 @@ export default function SetupWizard() {
<AdminAccountStep
username={username}
password={password}
telemetryEnabled={settings.system.enable_telemetry}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onTelemetryChange={(enable_telemetry) => setSettings((current) => ({ ...current, system: { ...current.system, enable_telemetry } }))}
registerValidator={registerValidator}
/>
);

View File

@@ -1,6 +1,11 @@
import { useState, useEffect } from "react";
import { Activity, Save } from "lucide-react";
import { apiAction, apiJson, isApiError } from "../lib/api";
import {
TELEMETRY_TEMPORARILY_DISABLED,
TELEMETRY_TEMPORARILY_DISABLED_MESSAGE,
TELEMETRY_USAGE_COPY,
} from "../lib/telemetryAvailability";
import { showToast } from "../lib/toast";
import LibraryDoctor from "./LibraryDoctor";
@@ -59,7 +64,7 @@ export default function SystemSettings() {
const fetchSettings = async () => {
try {
const data = await apiJson<SystemSettingsPayload>("/api/settings/system");
setSettings(data);
setSettings({ ...data, enable_telemetry: false });
setError("");
} catch (err) {
setError(isApiError(err) ? err.message : "Unable to load system settings.");
@@ -77,7 +82,7 @@ export default function SystemSettings() {
try {
await apiAction("/api/settings/system", {
method: "POST",
body: JSON.stringify(settings),
body: JSON.stringify({ ...settings, enable_telemetry: false }),
});
setSuccess(true);
showToast({ kind: "success", title: "System", message: "System settings saved." });
@@ -279,16 +284,19 @@ export default function SystemSettings() {
<h4 className="text-xs font-medium text-helios-slate">
Anonymous Telemetry
</h4>
<p className="text-xs text-helios-slate mt-1">Help improve the app by sending anonymous crash reports and usage data.</p>
<p className="mt-1 text-xs text-helios-slate">{TELEMETRY_TEMPORARILY_DISABLED_MESSAGE}</p>
<p className="mt-1 text-xs text-helios-slate/80">{TELEMETRY_USAGE_COPY}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.enable_telemetry}
aria-label="Anonymous Telemetry"
checked={false}
disabled={TELEMETRY_TEMPORARILY_DISABLED}
onChange={(e) => setSettings({ ...settings, enable_telemetry: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-helios-line/20 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-helios-solar"></div>
<div className="w-11 h-6 rounded-full bg-helios-line/20 peer-focus:outline-none after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:content-[''] after:transition-all peer-checked:after:translate-x-full peer-checked:after:border-white peer-checked:bg-helios-solar rtl:peer-checked:after:-translate-x-full peer-disabled:cursor-not-allowed peer-disabled:opacity-60"></div>
</label>
</div>
</div>

View File

@@ -1,36 +1,34 @@
import { useEffect } from "react";
import { motion } from "framer-motion";
import { UserCircle } from "lucide-react";
import {
TELEMETRY_TEMPORARILY_DISABLED,
TELEMETRY_TEMPORARILY_DISABLED_MESSAGE,
TELEMETRY_USAGE_COPY,
} from "../../lib/telemetryAvailability";
import { ToggleRow } from "./SetupControls";
import type { StepValidator } from "./types";
interface AdminAccountStepProps {
username: string;
password: string;
telemetryEnabled: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onTelemetryChange: (value: boolean) => void;
registerValidator: (validator: StepValidator) => void;
}
export default function AdminAccountStep({
username,
password,
telemetryEnabled,
onUsernameChange,
onPasswordChange,
onTelemetryChange,
registerValidator,
}: AdminAccountStepProps) {
useEffect(() => {
registerValidator(async () => {
if (!username.trim() || !password.trim()) {
return "Please provide an admin username and password.";
}
return null;
});
}, [password, registerValidator, username]);
registerValidator(async () => {
if (!username.trim() || !password.trim()) {
return "Please provide an admin username and password.";
}
return null;
});
return (
<motion.div
@@ -81,23 +79,12 @@ export default function AdminAccountStep({
<ToggleRow
title="Anonymous Usage Telemetry"
body="Alchemist can send anonymous, non-identifying signals to help improve hardware compatibility and default settings. No file names, paths, library contents, or personal data are ever collected. Off by default."
checked={telemetryEnabled}
onChange={onTelemetryChange}
body={TELEMETRY_TEMPORARILY_DISABLED_MESSAGE}
checked={false}
onChange={() => undefined}
disabled={TELEMETRY_TEMPORARILY_DISABLED}
>
<details>
<summary className="flex list-none items-center gap-1 cursor-pointer select-none text-xs text-helios-solar hover:underline">
What gets sent?
</summary>
<ul className="mt-2 list-disc space-y-1 pl-4 text-xs text-helios-slate/80">
<li>App version and OS/architecture</li>
<li>Whether running in Docker</li>
<li>CPU core count and total RAM (no identifiers)</li>
<li>Encoder type (NVENC, QSV, CPU, etc.)</li>
<li>Codec and resolution bucket (1080p, 4K) no filenames</li>
<li>Encode speed and success/failure outcome</li>
</ul>
</details>
<p className="text-xs text-helios-slate/80">{TELEMETRY_USAGE_COPY}</p>
</ToggleRow>
</div>
</motion.div>

View File

@@ -51,25 +51,23 @@ export default function LibraryStep({
}
}, [directories, onPreviewChange]);
useEffect(() => {
registerValidator(async () => {
if (directories.length === 0) {
return "Select at least one server folder before continuing.";
}
let nextPreview: FsPreviewResponse | null;
try {
nextPreview = await fetchPreview();
} catch (err) {
return err instanceof Error
? err.message
: "Failed to preview the selected folders. Double-check the path and that the Alchemist server can read it.";
}
if (nextPreview && nextPreview.total_media_files === 0) {
return "Preview did not find any supported media files yet. Double-check the chosen folders.";
}
return null;
});
}, [directories, fetchPreview, registerValidator]);
registerValidator(async () => {
if (directories.length === 0) {
return "Select at least one server folder before continuing.";
}
let nextPreview: FsPreviewResponse | null;
try {
nextPreview = await fetchPreview();
} catch (err) {
return err instanceof Error
? err.message
: "Failed to preview the selected folders. Double-check the path and that the Alchemist server can read it.";
}
if (nextPreview && nextPreview.total_media_files === 0) {
return "Preview did not find any supported media files yet. Double-check the chosen folders.";
}
return null;
});
useEffect(() => {
if (directories.length === 0) {

View File

@@ -30,6 +30,7 @@ interface ToggleRowProps {
checked: boolean;
onChange: (checked: boolean) => void;
children?: ReactNode;
disabled?: boolean;
}
interface ReviewCardProps {
@@ -81,13 +82,13 @@ export function LabeledSelect({ label, value, onChange, options }: LabeledSelect
);
}
export function ToggleRow({ title, body, checked, onChange, children }: ToggleRowProps) {
export function ToggleRow({ title, body, checked, onChange, children, disabled = false }: ToggleRowProps) {
const inputId = useId();
return (
<div className="rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4">
<div className={`rounded-lg border border-helios-line/20 bg-helios-surface-soft/40 px-4 py-4 ${disabled ? "opacity-70" : ""}`}>
<div className="flex items-start justify-between gap-4">
<label htmlFor={inputId} className="block flex-1 cursor-pointer">
<label htmlFor={inputId} className={`block flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
<p className="text-sm font-semibold text-helios-ink">{title}</p>
<p className="mt-1 text-xs text-helios-slate">{body}</p>
</label>
@@ -96,6 +97,7 @@ export function ToggleRow({ title, body, checked, onChange, children }: ToggleRo
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="mt-0.5 h-5 w-5 shrink-0 rounded border-helios-line/30 accent-helios-solar"
/>
</div>

View File

@@ -56,6 +56,18 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
</div>
</div>
{error && (
<div className="mx-6 pb-4">
<div
role="alert"
aria-live="polite"
className="mx-auto max-w-4xl rounded-lg border border-status-error/30 bg-status-error/10 px-4 py-3 text-sm text-status-error"
>
{error}
</div>
</div>
)}
{/* Navigation footer */}
{step < 6 && (
<div className="shrink-0 border-t border-helios-line/20

View File

@@ -96,10 +96,7 @@ export function mergeSetupSettings(status: SetupStatusResponse, bundle: Settings
system: {
...DEFAULT_SETTINGS.system,
...bundle.settings.system,
enable_telemetry:
typeof status.enable_telemetry === "boolean"
? status.enable_telemetry
: bundle.settings.system.enable_telemetry,
enable_telemetry: false,
},
};
}

View File

@@ -0,0 +1,5 @@
export const TELEMETRY_TEMPORARILY_DISABLED = true;
export const TELEMETRY_TEMPORARILY_DISABLED_MESSAGE =
"Temporarily unavailable while Alembic stabilizes. Telemetry stays off for now.";
export const TELEMETRY_USAGE_COPY =
"Anonymous usage telemetry helps improve hardware compatibility and default settings. It does not include file names, paths, or library contents.";