mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
Add UI regression coverage for telemetry and hardware settings
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
5
web/src/lib/telemetryAvailability.ts
Normal file
5
web/src/lib/telemetryAvailability.ts
Normal 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.";
|
||||
Reference in New Issue
Block a user