fix: show active qBittorrent transfers
This commit is contained in:
@@ -100,7 +100,10 @@ function ServiceConfigFields({
|
||||
const properties =
|
||||
(
|
||||
type.config_schema as {
|
||||
properties?: Record<string, { type?: string; description?: string; format?: string }>;
|
||||
properties?: Record<
|
||||
string,
|
||||
{ type?: string; description?: string; format?: string }
|
||||
>;
|
||||
}
|
||||
).properties ?? {};
|
||||
// Multi-line resizable textarea for fields that hold complex values (opt-in
|
||||
@@ -110,40 +113,41 @@ function ServiceConfigFields({
|
||||
<div className="flex flex-col gap-3">
|
||||
{Object.entries(properties).map(([key, schema]) => {
|
||||
const isNumber = schema.type === "integer" || schema.type === "number";
|
||||
const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key);
|
||||
const isTextarea =
|
||||
schema.format === "textarea" || TEXTAREA_KEYS.has(key);
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
label={key}
|
||||
htmlFor={`cfg-${key}`}
|
||||
helper={schema.description}
|
||||
>
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={`cfg-${key}`}
|
||||
rows={6}
|
||||
className="resize font-mono text-xs min-h-[120px]"
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`cfg-${key}`}
|
||||
type={isNumber ? "number" : "text"}
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
[key]: isNumber
|
||||
? e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
key={key}
|
||||
label={key}
|
||||
htmlFor={`cfg-${key}`}
|
||||
helper={schema.description}
|
||||
>
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={`cfg-${key}`}
|
||||
rows={6}
|
||||
className="resize font-mono text-xs min-h-[120px]"
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`cfg-${key}`}
|
||||
type={isNumber ? "number" : "text"}
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
[key]: isNumber
|
||||
? e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,12 @@ const SERVICE_OPTIONS = [
|
||||
// maps to this sentinel and converts back to "" at the draft boundary.
|
||||
const NONE = "__none__";
|
||||
|
||||
type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
|
||||
type SettingsTab =
|
||||
| "machines"
|
||||
| "ssh-keys"
|
||||
| "services"
|
||||
| "dashboards"
|
||||
| "danger";
|
||||
|
||||
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
||||
function FormField({
|
||||
@@ -1018,6 +1023,9 @@ export function Settings() {
|
||||
<TabsTrigger key="services" value="services">
|
||||
Services
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="dashboards" value="dashboards">
|
||||
Dashboards
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="danger" value="danger">
|
||||
Danger Zone
|
||||
</TabsTrigger>,
|
||||
@@ -1204,6 +1212,7 @@ export function Settings() {
|
||||
{tab === "services" && (
|
||||
<ServicesAdminCard initialServiceId={initialServiceId} />
|
||||
)}
|
||||
{tab === "dashboards" && <DashboardManagementCard />}
|
||||
{tab === "danger" && <ResetLocalDatabaseCard />}
|
||||
</TabbedCard>
|
||||
{isMobile ? (
|
||||
@@ -1354,7 +1363,6 @@ function ServicesAdminCard({
|
||||
const { data: services = [] } = useServiceInstances();
|
||||
const { data: types = [] } = useServiceTypes();
|
||||
const [selectedServiceId, setSelectedServiceId] = useState(initialServiceId);
|
||||
const [serviceSubtab, setServiceSubtab] = useState<"list" | "dashboards">("list");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const sortedServices = useMemo(
|
||||
@@ -1381,89 +1389,76 @@ function ServicesAdminCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedCard
|
||||
value={serviceSubtab}
|
||||
onChange={(value) => setServiceSubtab(value as "list" | "dashboards")}
|
||||
tabs={[
|
||||
<TabsTrigger key="list" value="list">List</TabsTrigger>,
|
||||
<TabsTrigger key="dashboards" value="dashboards">Dashboards</TabsTrigger>,
|
||||
]}
|
||||
contentSx={{}}
|
||||
>
|
||||
{serviceSubtab === "list" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
{sortedServices.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No service instances configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Services"
|
||||
description="Select a service to edit its configuration."
|
||||
minHeight={420}
|
||||
>
|
||||
{sortedServices.map((svc) => {
|
||||
const active = svc.id === (selectedService?.id ?? "");
|
||||
const typeName =
|
||||
types.find((t) => t.service_type === svc.service_type)?.name ??
|
||||
svc.service_type;
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
onClick={() => setSelectedServiceId(svc.id)}
|
||||
className={cn(
|
||||
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
|
||||
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{svc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||
Add service
|
||||
</Button>
|
||||
</div>
|
||||
{sortedServices.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No service instances configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<SelectionRailCard
|
||||
title="Services"
|
||||
description="Select a service to edit its configuration."
|
||||
minHeight={420}
|
||||
>
|
||||
{sortedServices.map((svc) => {
|
||||
const active = svc.id === (selectedService?.id ?? "");
|
||||
const typeName =
|
||||
types.find((t) => t.service_type === svc.service_type)
|
||||
?.name ?? svc.service_type;
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
onClick={() => setSelectedServiceId(svc.id)}
|
||||
className={cn(
|
||||
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
|
||||
active ? "bg-muted" : "bg-card hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{svc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
||||
{svc.enabled ? "on" : "off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectionRailCard>
|
||||
<SectionCard
|
||||
title={selectedService?.name ?? "No service selected"}
|
||||
description={
|
||||
selectedTypeInfo?.description ??
|
||||
"Select a service on the left to edit its configuration."
|
||||
}
|
||||
>
|
||||
{selectedService ? (
|
||||
<ServiceConfigEditor
|
||||
// Remount on service switch so useState initializers (name, config,
|
||||
// secrets) re-run for the new instance. Without this key, switching
|
||||
// services in the rail keeps the previous service's editable state
|
||||
// and a save writes stale name/config onto the new service's row.
|
||||
key={selectedService.id}
|
||||
instance={selectedService}
|
||||
typeInfo={selectedTypeInfo}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a service on the left.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
<Badge variant={svc.enabled ? "default" : "secondary"}>
|
||||
{svc.enabled ? "on" : "off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectionRailCard>
|
||||
<SectionCard
|
||||
title={selectedService?.name ?? "No service selected"}
|
||||
description={
|
||||
selectedTypeInfo?.description ??
|
||||
"Select a service on the left to edit its configuration."
|
||||
}
|
||||
>
|
||||
{selectedService ? (
|
||||
<ServiceConfigEditor
|
||||
// Remount on service switch so useState initializers (name, config,
|
||||
// secrets) re-run for the new instance. Without this key, switching
|
||||
// services in the rail keeps the previous service's editable state
|
||||
// and a save writes stale name/config onto the new service's row.
|
||||
key={selectedService.id}
|
||||
instance={selectedService}
|
||||
typeInfo={selectedTypeInfo}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a service on the left.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{serviceSubtab === "dashboards" && <DashboardManagementCard />}
|
||||
</TabbedCard>
|
||||
)}
|
||||
</div>
|
||||
<CreateServiceDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Settings > Services editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("offers service creation and dashboard management as Services subtabs", async () => {
|
||||
it("offers service creation and a separate Settings dashboards tab", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings?tab=services"]}>
|
||||
<Settings />
|
||||
@@ -159,7 +159,11 @@ describe("Settings > Services editor", () => {
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Dashboards" }));
|
||||
expect(screen.getByRole("heading", { name: "Dashboards" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "New dashboard" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Dashboards" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "New dashboard" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user