fix: simplify custom widget package workbench

This commit is contained in:
ajnart committed 2026-09-12 15:33:51 +02:00
1 parent 9a52a035aa
commit 48042a08ff
37 files changed
+1155 -608

No files matched your search

@@ -14,7 +14,7 @@ export const packageAssistantSdkContext = {
services:
"useWidgetServices() exposes notify({title,message?,color?}), navigate(href), openAdvanced(), closeAdvanced(), openDetail({title,content:ReactNode}), closeDetail(), confirm({title,message,destructive?}):Promise<boolean>, updateOptions(record):Promise<void>. Use updateOptions from an authored configuration surface. Managed storage is isolated memory in preview. Query results and local options are not part of Assistant context.",
server:
"Optional server.ts imports @homarr/widget-sdk/server and default-exports defineWidgetServer({handler:async(input,context)=>value}). Declare every handler in manifest.handlers with kind:'query'|'action'|'subscription'|'migration', permission:'view'|'modify'|'full', inputSchema? (JSON Schema), guestAccess?; actions default to modify. fetchWidgetConnection<T>(context,{connection:'slot',path,method?,query?,body?,responseType?}) returns {data,ok,status,contentType,durationMs,updatedAt}. getWidgetConnection<TSettings>(context,{connection,serviceType?}) returns {configuration:{kind,serviceType?,settings?:TSettings,...},secrets:Record<string,string>} only to trusted server code for Node SQL, MQTT, SSH, files or other service clients; never return secrets to browser queries. getWidgetServerContext(context) returns options,installationId,bindingNames,isPreview. callWidgetIntegration(context,{connection,capability,input?}) invokes native adapters; subscribeWidgetIntegration yields native stream events. callWidgetRunner and subscribeWidgetRunner address owner-managed remote runners. Server modules have trusted Node capabilities. Secrets stay in owner-bound connections. Do not invent integration capabilities: use reference packages or declared capability docs.",
"Optional server.ts imports @homarr/widget-sdk/server and default-exports defineWidgetServer({handler:async(input,context)=>value}). Declare every handler in manifest.handlers with kind:'query'|'action'|'subscription'|'migration', permission:'view'|'modify'|'full', inputSchema? (JSON Schema), guestAccess?; actions default to modify. fetchWidgetConnection<T>(context,{connection:'slot',path,method?,query?,body?,responseType?}) returns {data,ok,status,contentType,durationMs,updatedAt}. getWidgetConnection<TSettings>(context,{connection,serviceType?}) returns {configuration:{kind,serviceType?,settings?:TSettings,...},secrets:Record<string,string>} only to trusted server code for Node SQL, MQTT, SSH, files or other service clients; never return secrets to browser queries. getWidgetServerContext(context) returns options,installationId,bindingNames,bindingLabels,isPreview. Declare connections with multiple:true for a repeatable group, and integrationKind (for example sonarr) to filter compatible native integrations. getWidgetConnectionGroup(context,slot) returns all {connection,label} members for queries and actions; do not hard-code first/second server slots. callWidgetIntegration(context,{connection,capability,input?}) invokes native adapters; subscribeWidgetIntegration yields native stream events. callWidgetRunner and subscribeWidgetRunner address owner-managed remote runners. Server modules have trusted Node capabilities. Secrets stay in owner-bound connections. Do not invent integration capabilities: use reference packages or declared capability docs.",
lifecycle:
"Proposals only change the visible draft after explicit review; they never save, trust, run or activate code. The owner saves the draft, reviews dependencies and trust, runs exact-candidate Preview, then explicitly activates the saved release for all placements. SDK actions are simulated in default previews, but arbitrary trusted browser/Node code still executes. A preview session proves compilation/session creation only, not successful UI rendering, live action success or all application behavior. Any source/options/binding edit invalidates preview evidence.",
};
@@ -1,9 +1,10 @@
"use client";
import { Button, Divider, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
import { Alert, Button, Divider, Group, Popover, Stack, Text, Textarea } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useState } from "react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context";
export function PackageAssistant() {
@@ -11,8 +12,10 @@ export function PackageAssistant() {
const assistant = useOptionalHomarrAssistant();
const [request, setRequest] = useState("");
const [opened, setOpened] = useState(false);
if (!assistant?.enabled) return null;
const [sendFailed, setSendFailed] = useState(false);
const send = (intent: string) => {
if (!assistant?.enabled || assistant.isRunning) return;
setSendFailed(false);
assistant.open();
if (
assistant.sendPrompt(
@@ -21,45 +24,69 @@ export function PackageAssistant() {
) {
setOpened(false);
setRequest("");
}
} else setSendFailed(true);
};
return (
<Popover opened={opened} onChange={setOpened} width={320} withinPortal position="bottom-start">
<Popover.Target>
<Button size="xs" variant="light" leftSection={<IconSparkles size={14} />} disabled={assistant.isRunning}>
<Button
size="xs"
variant="light"
leftSection={<IconSparkles size={14} />}
onClick={() => setOpened((value) => !value)}
aria-expanded={opened}
>
{t("open")}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Text size="xs" c="dimmed" mb="xs">
{t("context")}
</Text>
<Textarea
mx="xs"
mb="xs"
label={t("request")}
placeholder={t("requestPlaceholder")}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
minRows={3}
/>
<Group px="xs" pb="xs">
<Button size="xs" disabled={!request.trim()} onClick={() => send(request)}>
{t("send")}
</Button>
</Group>
<Divider my="xs" />
<Stack gap={4}>
<Button variant="subtle" size="xs" justify="start" onClick={() => send(t("improvePrompt"))}>
{t("improve")}
</Button>
<Button variant="subtle" size="xs" justify="start" onClick={() => send(t("advancedPrompt"))}>
{t("advanced")}
</Button>
<Button variant="subtle" size="xs" justify="start" onClick={() => send(t("fixPrompt"))}>
{t("fix")}
</Button>
</Stack>
{!assistant?.enabled && (
<Stack gap="sm">
<Text size="sm">{assistant?.unavailableDescription || t("unavailable")}</Text>
<Button component={Link} href="/manage/assistant" variant="light" size="xs">
{t("setup")}
</Button>
<Text size="xs" c="dimmed">{t("manual")}</Text>
</Stack>
)}
{assistant?.enabled && (
<>
{assistant.isRunning && <Alert mb="xs">{t("busy")}</Alert>}
{sendFailed && <Alert color="yellow" mb="xs">{t("sendFailed")}</Alert>}
<Text size="xs" c="dimmed" mb="xs">
{t("context")}
</Text>
<Textarea
mx="xs"
mb="xs"
label={t("request")}
placeholder={t("requestPlaceholder")}
value={request}
onChange={(event) => setRequest(event.currentTarget.value)}
minRows={3}
/>
<Group px="xs" pb="xs">
<Button size="xs" disabled={!request.trim() || assistant.isRunning} onClick={() => send(request)}>
{t("send")}
</Button>
</Group>
<Divider my="xs" />
<Stack gap={4}>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("createPrompt"))}>
{t("create")}
</Button>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("improvePrompt"))}>
{t("improve")}
</Button>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("advancedPrompt"))}>
{t("advanced")}
</Button>
<Button variant="subtle" size="xs" justify="start" disabled={assistant.isRunning} onClick={() => send(t("fixPrompt"))}>
{t("fix")}
</Button>
</Stack>
</>
)}
</Popover.Dropdown>
</Popover>
);
@@ -0,0 +1,98 @@
"use client";
import { useRef, useState } from "react";
import { Alert, MultiSelect, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
export function PackageBindingPicker({ name, requirement, connections, integrations, bindings, onChange }: {
name: string;
requirement: CustomWidgetPackage["connections"][string];
connections: RouterOutputs["customWidget"]["package"]["connections"];
integrations: RouterOutputs["integration"]["all"];
bindings: Record<string, string>;
onChange(bindings: Record<string, string>): void;
}) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const saveConnection = clientApi.customWidget.package.saveConnection.useMutation();
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const currentBindings = useRef(bindings);
currentBindings.current = bindings;
const ids = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]!);
const compatible = connections.filter((connection) => isWidgetConnectionCompatible(requirement, connection.configuration));
const choices = compatible.map((connection) => ({ value: connection.id, label: connection.name }));
if (requirement.kind === "integration") {
for (const integration of integrations) {
if (requirement.integrationKind && integration.kind !== requirement.integrationKind) continue;
if (compatible.some((connection) => connection.integrationId === integration.id)) continue;
choices.push({ value: `integration:${integration.id}`, label: integration.name });
}
}
// Keep unavailable bindings visible so a removed integration can be diagnosed or replaced.
for (const id of ids) {
if (!choices.some((choice) => choice.value === id))
choices.push({ value: id, label: connections.find((connection) => connection.id === id)?.name ?? t("unavailableConnection") });
}
const change = async (values: string[]) => {
if (pending) return;
setPending(true);
setError("");
try {
const nextIds: string[] = [];
for (const value of values) {
if (!value.startsWith("integration:")) {
nextIds.push(value);
continue;
}
const integration = integrations.find((entry) => `integration:${entry.id}` === value);
if (!integration) throw new Error(t("unavailableConnection"));
const result = await saveConnection.mutateAsync({
name: integration.name,
integrationId: integration.id,
configuration: { kind: "integration" },
});
nextIds.push(result.id);
}
await utils.customWidget.package.connections.invalidate();
const next = { ...currentBindings.current };
for (const key of getWidgetConnectionBindingNames(name, requirement, next)) delete next[key];
for (const [index, id] of nextIds.entries()) {
let key = name;
if (index > 0) key = `${name}:${id}`;
next[key] = id;
}
onChange(next);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setPending(false);
}
};
const common = {
label: requirement.label,
description: requirement.description,
placeholder: t("chooseConnection"),
required: !requirement.optional,
searchable: true,
clearable: true,
disabled: pending,
data: choices,
nothingFoundMessage: t("noCompatibleConnections"),
};
return (
<Stack gap="xs" flex={1}>
{requirement.multiple ? (
<MultiSelect {...common} value={ids} onChange={(values) => void change(values)} />
) : (
<Select {...common} value={ids[0] ?? null} onChange={(value) => void change(value ? [value] : [])} />
)}
{error && <Alert color="red">{error}</Alert>}
</Stack>
);
}
@@ -2,15 +2,16 @@
import { ConnectionEditor } from "./_package-connection-editor";
import { PackageConnectionDiagnostic } from "./_package-connection-diagnostic";
import { PackageBindingPicker } from "./_package-binding-picker";
import type { RouterOutputs } from "@homarr/api";
import { useState } from "react";
import { Alert, Button, Group, Paper, Select, Stack, Text } from "@mantine/core";
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { isRecord } from "@homarr/common";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames } from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export function PackageConnections({
requirements,
@@ -30,6 +31,7 @@ export function PackageConnections({
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const connections = clientApi.customWidget.package.connections.useQuery();
const integrations = clientApi.integration.all.useQuery();
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [diagnostic, setDiagnostic] = useState<RouterOutputs["customWidget"]["package"]["diagnoseConnection"] | null>(
@@ -45,65 +47,66 @@ export function PackageConnections({
{Object.keys(requirements).length === 0 && <Alert>{t("noConnections")}</Alert>}
{Object.entries(requirements).map(([name, requirement]) => (
<Paper key={name} withBorder p="sm">
<Group align="end">
<Select
flex={1}
label={requirement.label}
description={requirement.description}
required={!requirement.optional}
clearable
searchable
value={bindings[name] ?? null}
data={(connections.data ?? [])
.filter(
(connection) =>
isRecord(connection.configuration) &&
isWidgetConnectionCompatible(requirement, connection.configuration),
)
.map((connection) => ({ value: connection.id, label: connection.name }))}
onChange={(value) => {
const next = { ...bindings };
delete next[name];
if (value) next[name] = value;
onChange(next);
}}
<Stack gap="xs">
<PackageBindingPicker
name={name}
requirement={requirement}
connections={connections.data ?? []}
integrations={integrations.data ?? []}
bindings={bindings}
onChange={onChange}
/>
<Button
variant="default"
disabled={!bindings[name]}
loading={diagnose.isPending}
onClick={() => {
const id = bindings[name];
if (!id) return;
setDiagnostic(null);
setDiagnosticError("");
diagnose.mutate(
{ id },
{
onSuccess: setDiagnostic,
onError: (error) => setDiagnosticError(error.message),
},
);
}}
>
{t("diagnose")}
</Button>
<Button
variant="subtle"
disabled={!bindings[name]}
onClick={() => {
setCreating(false);
setEditingId(bindings[name] ?? null);
}}
>
{t("editConnection")}
</Button>
</Group>
{getWidgetConnectionBindingNames(name, requirement, bindings).map((bindingName) => (
<Group key={bindingName} gap="xs">
<Text size="sm" flex={1}>{connections.data?.find((entry) => entry.id === bindings[bindingName])?.name ?? bindingName}</Text>
<Button
variant="default"
size="xs"
loading={diagnose.isPending}
onClick={() => {
const id = bindings[bindingName];
if (!id) return;
setDiagnostic(null);
setDiagnosticError("");
diagnose.mutate(
{ id },
{
onSuccess: setDiagnostic,
onError: (error) => setDiagnosticError(error.message),
},
);
}}
>
{t("diagnose")}
</Button>
<Button
variant="subtle"
size="xs"
onClick={() => {
setCreating(false);
setEditingId(bindings[bindingName] ?? null);
}}
>
{t("editConnection")}
</Button>
</Group>
))}
</Stack>
</Paper>
))}
{diagnostic && <PackageConnectionDiagnostic result={diagnostic} />}
{diagnosticError && <Alert color="red">{diagnosticError}</Alert>}
{connections.error && <Alert color="red">{connections.error.message}</Alert>}
{integrations.error && <Alert color="red">{integrations.error.message}</Alert>}
<Group>
{Object.values(requirements).some((requirement) => requirement.kind === "integration") && (
<Button component={Link} href="/manage/integrations/new" target="_blank" rel="noopener noreferrer" variant="light">
{t("createIntegration")}
</Button>
)}
<Button variant="subtle" onClick={() => { void connections.refetch(); void integrations.refetch(); }}>
{t("refresh")}
</Button>
<Button
variant="default"
onClick={() => {
@@ -0,0 +1,69 @@
"use client";
import { useMemo } from "react";
import { Group, Text, Tree, useTree } from "@mantine/core";
import type { TreeNodeData } from "@mantine/core";
import { IconChevronRight, IconCode, IconFolder, IconFolderOpen } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
export function PackageFileTree({ files, selected, onSelect }: {
files: Record<string, string>;
selected: string;
onSelect(path: string): void;
}) {
const t = useI18n("customWidget.package");
const data = useMemo(() => {
const roots: TreeNodeData[] = [{ value: "/widget.json", label: t("manifestFile") }];
for (const path of Object.keys(files).toSorted()) {
const parts = path.split("/");
let siblings = roots;
let prefix = "";
for (const [index, label] of parts.entries()) {
prefix += label;
if (index === parts.length - 1) {
siblings.push({ value: path, label });
break;
}
prefix += "/";
let folder = siblings.find((node) => node.value === prefix);
if (!folder) {
folder = { value: prefix, label, children: [] };
siblings.push(folder);
}
siblings = folder.children ?? [];
}
}
return roots;
}, [files, t]);
const tree = useTree({
selectedState: [selected],
onSelectedStateChange: (values) => {
const value = values[0];
if (value && !value.endsWith("/")) onSelect(value);
},
});
return (
<Tree
aria-label={t("files")}
data={data}
tree={tree}
levelOffset={16}
selectOnClick
renderNode={({ node, expanded, hasChildren, elementProps }) => {
let Icon = IconCode;
if (hasChildren) Icon = IconFolder;
if (hasChildren && expanded) Icon = IconFolderOpen;
return (
<Group {...elementProps} gap={6} wrap="nowrap" py={5} title={node.value}>
{hasChildren && <IconChevronRight size={12} style={{ transform: `rotate(${expanded ? 90 : 0}deg)` }} />}
<Icon size={16} style={{ flexShrink: 0 }} />
<Text size="xs" truncate>
{node.label}
</Text>
</Group>
);
}}
/>
);
}
@@ -1,8 +1,8 @@
"use client";
import { useRef, useState } from "react";
import { Alert, Badge, Button, Stack, Text, TextInput } from "@mantine/core";
import { IconPackage, IconUpload } from "@tabler/icons-react";
import { Alert, Avatar, Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconPackage, IconPencil, IconUpload } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -12,21 +12,20 @@ import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { PackageRowActions } from "./_package-row-actions";
export function PackageList({ initialData }: { initialData: RouterOutputs["customWidget"]["package"]["list"] }) {
const t = useI18n("customWidget.package");
const tCommon = useI18n("common");
const installations = clientApi.customWidget.package.list.useQuery(undefined, { initialData });
const [filter, setFilter] = useState("");
const filtered = (installations.data ?? []).filter((entry) =>
entry.name.toLowerCase().includes(filter.toLowerCase()),
);
const entries = installations.data ?? [];
return (
<Stack>
<Text c="dimmed">{t("libraryDescription")}</Text>
<TextInput label={t("search")} value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />
{filtered.length === 0 && <Alert>{t("empty")}</Alert>}
{entries.length === 0 && <NoResults icon={IconPackage} title={t("empty")} action={{ href: "/manage/custom-widgets/packages/new", label: t("create") }} />}
<ManageCollectionList ariaLabel={t("title")}>
{filtered.map((entry) => {
{entries.map((entry) => {
let status = t("draftOnly");
let color = "gray";
if (entry.activeArtifactId) {
@@ -39,7 +38,7 @@ export function PackageList({ initialData }: { initialData: RouterOutputs["custo
return (
<ManageCollectionItem
key={entry.id}
leading={<IconPackage size={26} stroke={1.4} />}
leading={<Avatar size={40} radius="sm" color="pink"><IconPackage size={20} stroke={1.5} /></Avatar>}
title={<Text fw={600}>{entry.name}</Text>}
badges={
<Badge color={color} variant="light">
@@ -52,10 +51,14 @@ export function PackageList({ initialData }: { initialData: RouterOutputs["custo
</Text>
}
actions={
<Button component={Link} href={`/manage/custom-widgets/packages/${entry.id}`} variant="default">
{t("openWorkspace")}
<Group gap="xs" wrap="nowrap">
<Button component={Link} href={`/manage/custom-widgets/packages/${entry.id}`} variant="default" size="sm" leftSection={<IconPencil size={16} stroke={1.5} />}>
{tCommon("action.edit")}
</Button>
<PackageRowActions entry={entry} />
</Group>
}
actionsAlignment="center"
/>
);
})}
@@ -8,12 +8,9 @@ import { clientApi } from "@homarr/api/client";
import { customWidgetPackageSchema, stringifyWidgetJson } from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
import { AddPackagePlacement, PackagePlacements } from "./_package-placements";
import type { PackageOperation } from "./_package-document";
import { PackageWorkshop } from "./_package-workshop";
import { PackageTransfer } from "./_package-transfer";
import { PackageArtifactReview } from "./_package-artifact-review";
import { PackageConvertedPlacements } from "./_package-converted-placements";
import { downloadPackage } from "./_package-document";
@@ -25,9 +22,12 @@ export function PackageOperations({
initialBoardId,
source,
options,
setOptions,
dirty,
trusted,
connectionsDirty,
onTrustChange,
onSave,
saving,
onError,
onMessage,
}: {
@@ -35,9 +35,12 @@ export function PackageOperations({
initialBoardId?: string;
source?: CustomWidgetPackage;
options: Record<string, unknown>;
setOptions(options: Record<string, unknown>): void;
dirty: boolean;
trusted: boolean;
connectionsDirty: boolean;
onTrustChange(trusted: boolean): void;
onSave(): void;
saving: boolean;
onError(error: string): void;
onMessage(message: string): void;
}) {
@@ -88,32 +91,34 @@ export function PackageOperations({
};
return (
<Stack>
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("previewOptions")}</Text>
{source && <PackageOptionFields schema={source.options} value={options} onChange={setOptions} />}
<Button variant="subtle" onClick={() => setOptions({})}>
{t("resetOptions")}
</Button>
</Stack>
</Paper>
{!current && <Alert>{t("saveFirst")}</Alert>}
<Text size="sm" c="dimmed">{t("useDescription")}</Text>
{!current && (
<Paper withBorder p="md">
<Stack>
<Text>{t("saveFirst")}</Text>
<Button onClick={onSave} loading={saving}>{t("saveDraft")}</Button>
</Stack>
</Paper>
)}
{current && (
<>
<Paper withBorder p="md">
<Stack>
<Text fw={600}>{t("activation")}</Text>
<Text size="sm">{t("activationDescription", { count: current.placements.length })}</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{t("activeArtifact")}: {current.activeArtifactId ?? t("none")}
</Text>
{dirty && <Alert color="yellow">{t("saveBeforeActivate")}</Alert>}
{dirty && (
<Alert color="yellow">
<Stack gap="xs">
<Text size="sm">{t("saveBeforeActivate")}</Text>
<Button size="xs" onClick={onSave} loading={saving}>{t("saveDraft")}</Button>
</Stack>
</Alert>
)}
{!dirty && !matchesReviewedSource && <Alert color="yellow">{t("savedSourceChanged")}</Alert>}
{inspected.data?.artifact && <PackageArtifactReview artifact={inspected.data.artifact} />}
{inspected.error && <Alert color="red">{inspected.error.message}</Alert>}
<Checkbox checked={trusted} onChange={(event) => onTrustChange(event.currentTarget.checked)} label={t("trustAcknowledgement")} />
<Group>
<Button
disabled={dirty || !trusted || !source || !matchesReviewedSource}
disabled={dirty || connectionsDirty || !trusted || !source || !matchesReviewedSource}
loading={activate.isPending}
onClick={() =>
void run(
@@ -129,50 +134,66 @@ export function PackageOperations({
>
{t("activate")}
</Button>
{current.activeArtifactId && (
<Button
variant="default"
loading={enable.isPending}
onClick={() =>
void run(() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }), t("stateSaved"))
}
>
{current.enabled ? t("disable") : t("enable")}
</Button>
)}
<Button
variant="default"
disabled={!exported.data}
onClick={() => {
if (exported.data) downloadPackage(current.name, exported.data);
}}
>
{t("exportActive")}
</Button>
</Group>
{current.previousArtifactId && (
<>
<Checkbox
checked={rollbackConfirmed}
onChange={(event) => setRollbackConfirmed(event.currentTarget.checked)}
label={t("rollbackAcknowledgement")}
/>
<Button
variant="light"
color="orange"
disabled={!rollbackConfirmed}
loading={rollback.isPending}
onClick={() =>
void run(
() => rollback.mutateAsync({ id: current.id, discardNewerWidgetData: true }),
t("rolledBack"),
)
}
>
{t("rollback")}
</Button>
</>
)}
<Accordion>
<Accordion.Item value="release">
<Accordion.Control>{t("releaseTools")}</Accordion.Control>
<Accordion.Panel>
<Stack>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{t("activeArtifact")}: {current.activeArtifactId ?? t("none")}
</Text>
{inspected.data?.artifact && <PackageArtifactReview artifact={inspected.data.artifact} />}
{inspected.error && <Alert color="red">{inspected.error.message}</Alert>}
<Group>
{current.activeArtifactId && (
<Button
variant="default"
loading={enable.isPending}
onClick={() =>
void run(() => enable.mutateAsync({ id: current.id, enabled: !current.enabled }), t("stateSaved"))
}
>
{current.enabled ? t("disable") : t("enable")}
</Button>
)}
<Button
variant="default"
disabled={!exported.data}
onClick={() => {
if (exported.data) downloadPackage(current.name, exported.data);
}}
>
{t("exportActive")}
</Button>
</Group>
{current.previousArtifactId && (
<>
<Checkbox
checked={rollbackConfirmed}
onChange={(event) => setRollbackConfirmed(event.currentTarget.checked)}
label={t("rollbackAcknowledgement")}
/>
<Button
variant="light"
color="orange"
disabled={!rollbackConfirmed}
loading={rollback.isPending}
onClick={() =>
void run(
() => rollback.mutateAsync({ id: current.id, discardNewerWidgetData: true }),
t("rolledBack"),
)
}
>
{t("rollback")}
</Button>
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
<PackageConvertedPlacements
@@ -186,20 +207,13 @@ export function PackageOperations({
installation={current}
initialBoardId={initialBoardId}
source={activeSource.data}
initialOptions={options}
onAdded={() => void refresh()}
onError={onError}
onMessage={onMessage}
/>
)}
{activeSource.success && <PackagePlacements installation={current} source={activeSource.data} />}
<PackageTransfer
id={current.id}
name={current.name}
active={Boolean(current.activeArtifactId)}
dirty={dirty}
onError={onError}
/>
<PackageWorkshop id={current.id} dirty={dirty} />
<PackageActivity id={current.id} />
</>
)}
@@ -16,6 +16,7 @@ export function AddPackagePlacement({
installation,
initialBoardId,
source,
initialOptions,
onAdded,
onError,
onMessage,
@@ -23,6 +24,7 @@ export function AddPackagePlacement({
installation: Installation;
initialBoardId?: string;
source: CustomWidgetPackage;
initialOptions?: Record<string, unknown>;
onAdded(): void;
onError(error: string): void;
onMessage(message: string): void;
@@ -31,7 +33,7 @@ export function AddPackagePlacement({
const boards = clientApi.board.getAllBoards.useQuery();
const add = clientApi.board.addItem.useMutation();
const [boardId, setBoardId] = useState<string | null>(initialBoardId ?? null);
const [configuration, setConfiguration] = useState<Record<string, unknown>>({});
const [configuration, setConfiguration] = useState<Record<string, unknown>>(initialOptions ?? {});
const values = { ...getCustomWidgetDefaultOptions(source.options), ...configuration };
const missingConnections = Object.entries(source.connections).filter(
([name, requirement]) => !requirement.optional && !installation.bindings[name],
@@ -1,49 +1,17 @@
"use client";
import { Group, NumberInput, Select, Stack } from "@mantine/core";
import { Accordion, Group, NumberInput, Select, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import type { usePackageWorkspace } from "./_use-package-workspace";
const presets = {
compact: { width: 320, height: 260 },
wide: { width: 640, height: 320 },
phone: { width: 390, height: 640 },
tablet: { width: 768, height: 540 },
};
export function PackagePreviewControls({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
const t = useI18n("customWidget.package");
const { width, height, scale, previewTheme, setWidth, setHeight, setScale, setPreviewTheme } = state;
const labels = {
compact: t("viewportcompact"),
wide: t("viewportwide"),
phone: t("viewportphone"),
tablet: t("viewporttablet"),
};
let preset = "custom";
for (const [name, size] of Object.entries(presets)) {
if (size.width === width && size.height === height) preset = name;
}
return (
<Stack gap="xs">
<Group gap="xs" grow>
<Select
size="xs"
label={t("previewViewport")}
value={preset}
data={[
{ value: "custom", label: t("viewportCustom") },
...Object.entries(labels).map(([value, label]) => ({ value, label })),
]}
onChange={(value) => {
if (!value || !Object.hasOwn(presets, value)) return;
const size = presets[value as keyof typeof presets];
setWidth(size.width);
setHeight(size.height);
}}
/>
<Select
size="xs"
label={t("previewTheme")}
@@ -58,40 +26,50 @@ export function PackagePreviewControls({ state }: { state: ReturnType<typeof use
}}
/>
</Group>
<Group gap="xs" grow>
<NumberInput
size="xs"
label={t("width")}
min={100}
max={2400}
value={width}
onChange={(value) => {
if (typeof value === "number") setWidth(value);
}}
/>
<NumberInput
size="xs"
label={t("height")}
min={100}
max={1600}
value={height}
onChange={(value) => {
if (typeof value === "number") setHeight(value);
}}
/>
<NumberInput
size="xs"
label={t("previewScale")}
min={25}
max={200}
suffix="%"
step={10}
value={Math.round(scale * 100)}
onChange={(value) => {
if (typeof value === "number") setScale(Math.min(2, Math.max(0.25, value / 100)));
}}
/>
</Group>
<Accordion>
<Accordion.Item value="dimensions">
<Accordion.Control>{t("customViewport")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Text size="xs" c="dimmed">{t("customViewportDescription")}</Text>
<Group gap="xs" grow>
<NumberInput
size="xs"
label={t("width")}
min={100}
max={2400}
value={width}
onChange={(value) => {
if (typeof value === "number") setWidth(value);
}}
/>
<NumberInput
size="xs"
label={t("height")}
min={100}
max={1600}
value={height}
onChange={(value) => {
if (typeof value === "number") setHeight(value);
}}
/>
<NumberInput
size="xs"
label={t("previewScale")}
min={25}
max={90}
suffix="%"
step={10}
value={Math.round(scale * 100)}
onChange={(value) => {
if (typeof value === "number") setScale(Math.min(0.9, Math.max(0.25, value / 100)));
}}
/>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
@@ -1,27 +1,25 @@
"use client";
import dynamic from "next/dynamic";
import { Accordion, Alert, Button, Checkbox, Group, Paper, Select, Stack, Text } from "@mantine/core";
import { useState } from "react";
import { Accordion, Alert, Badge, Button, Checkbox, Group, Select, Stack, Text } from "@mantine/core";
import { IconCode, IconPlayerPlay } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { WidgetPreviewFrame } from "@homarr/widgets/preview";
import { PackagePreviewViewport } from "./_package-preview-viewport";
import { PackagePreviewControls } from "./_package-preview-controls";
import { PackageRuntimeStatus } from "./_package-runtime-status";
import { downloadPackage } from "./_package-document";
import type { usePackageWorkspace } from "./_use-package-workspace";
import classes from "./_package-workspace.module.css";
const TrustedWidgetPreview = dynamic(() => import("@homarr/widgets/custom-api/trusted-widget-preview"), { ssr: false });
export function PackagePreview({
state,
existing,
}: {
state: ReturnType<typeof usePackageWorkspace>;
existing: boolean;
}) {
export function PackagePreview({ state }: { state: ReturnType<typeof usePackageWorkspace> }) {
const t = useI18n("customWidget.package");
const [detailsOpen, setDetailsOpen] = useState<string | null>(null);
const {
parsed,
trusted,
@@ -49,6 +47,10 @@ export function PackagePreview({
previewLoadError,
setOptions,
} = state;
let buildStatus = t("previewNotBuilt");
if (previewMutation.isPending) buildStatus = t("previewBuilding");
if (previewMutation.isError) buildStatus = t("previewBuildFailed");
if (previewMutation.isSuccess) buildStatus = t("previewBuildReady");
return (
<Stack className={classes.preview} gap="sm">
<Group gap="xs" wrap="nowrap">
@@ -63,8 +65,8 @@ export function PackagePreview({
/>
<Button
leftSection={<IconPlayerPlay size={16} />}
loading={previewMutation.isPending}
disabled={!existing || !parsed.success || !trusted || save.isPending}
loading={previewMutation.isPending || save.isPending}
disabled={!parsed.success || !trusted || busy}
onClick={() => void runPreview()}
>
{t("preview")}
@@ -99,9 +101,9 @@ export function PackagePreview({
onChange={(event) => setLiveActions(event.currentTarget.checked)}
label={t("previewLiveActions")}
/>
{!existing && (
{!state.installationId && (
<Text size="xs" c="dimmed">
{t("saveFirst")}
{t("previewSavesFirst")}
</Text>
)}
{stale && (
@@ -109,32 +111,39 @@ export function PackagePreview({
{t("previewStale")}
</Alert>
)}
<Paper withBorder className={classes.canvas} p="xs">
{preview && (
<PackagePreviewViewport width={width} height={height} scale={scale} colorScheme={previewTheme}>
<TrustedWidgetPreview
data={preview.displayData}
width={width}
height={height}
displayScale={scale}
surface={surface}
onOptionsChange={setOptions}
onSurfaceChange={setSurface}
frozen={stale || previewMutation.isPending}
onReady={previewReady}
onLoadError={previewLoadError}
/>
</PackagePreviewViewport>
<WidgetPreviewFrame
dimensions={{ width, height, scale }}
resize={{ size: state.gridSize, maximumSize: { width: 12, height: 12 }, onChange: state.setGridSize }}
>
{({ scale: fittedScale }) => (
<>
{preview && (
<PackagePreviewViewport width={width} height={height} scale={fittedScale} colorScheme={previewTheme}>
<TrustedWidgetPreview
data={preview.displayData}
width={width}
height={height}
displayScale={fittedScale}
surface={surface}
onOptionsChange={setOptions}
onSurfaceChange={setSurface}
frozen={stale || previewMutation.isPending}
onReady={previewReady}
onLoadError={previewLoadError}
/>
</PackagePreviewViewport>
)}
{!preview && (
<Stack justify="center" align="center" mih={220} p="sm">
<IconCode size={32} stroke={1} />
<Text c="dimmed" size="sm" ta="center">
{t("previewEmpty")}
</Text>
</Stack>
)}
</>
)}
{!preview && (
<Stack justify="center" align="center" mih={220} p="sm">
<IconCode size={32} stroke={1} />
<Text c="dimmed" size="sm" ta="center">
{t("previewEmpty")}
</Text>
</Stack>
)}
</Paper>
</WidgetPreviewFrame>
<Group gap="xs" align="center">
{preview && (
<Text size="xs" c="dimmed">
@@ -148,12 +157,18 @@ export function PackagePreview({
)}
</Group>
<PackagePreviewControls state={state} />
<Accordion variant="default">
<Accordion variant="default" value={detailsOpen} onChange={setDetailsOpen}>
<Accordion.Item value="trust">
<Accordion.Control>{t("executionDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Badge variant="light">{buildStatus}</Badge>
{previewMutation.error && <Alert color="red">{previewMutation.error.message}</Alert>}
<Text size="sm">{t("browserExecution")}</Text>
<Text size="sm">{parsed.success && parsed.data.manifest.entrypoints.server ? t("serverExecution") : t("browserOnlyExecution")}</Text>
<Text size="sm">{t("trustedCode")}</Text>
<Text size="sm">{t("runtimeLogs")}</Text>
{detailsOpen && state.installationId && <PackageRuntimeStatus id={state.installationId} />}
<Text size="xs" c="dimmed">
{t("previewDescription")}
</Text>
@@ -0,0 +1,72 @@
"use client";
import { ActionIcon, Menu } from "@mantine/core";
import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconLayoutDashboard, IconPlayerPause, IconPlayerPlay, IconPlugConnected } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { downloadPackage } from "./_package-document";
export function PackageRowActions({ entry }: { entry: RouterOutputs["customWidget"]["package"]["list"][number] }) {
const t = useI18n("customWidget.package");
const utils = clientApi.useUtils();
const fork = clientApi.customWidget.package.fork.useMutation();
const enable = clientApi.customWidget.package.setEnabled.useMutation();
const busy = fork.isPending || enable.isPending;
const path = `/manage/custom-widgets/packages/${entry.id}`;
const run = async (action: () => Promise<unknown>) => {
try {
await action();
await utils.customWidget.package.list.invalidate();
} catch (error) {
showErrorNotification({ title: t("title"), message: error instanceof Error ? error.message : String(error) });
}
};
return (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("actionsMenu")} loading={busy}>
<IconDots size={20} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item component={Link} href={`${path}?tab=installation`} leftSection={<IconLayoutDashboard size={16} />}>
{t("addToBoard")}
</Menu.Item>
<Menu.Item component={Link} href={`${path}?tab=connections`} leftSection={<IconPlugConnected size={16} />}>
{t("connections")}
</Menu.Item>
<Menu.Item component={Link} href={`${path}?tab=workshop`} leftSection={<IconBuildingStore size={16} />}>
{t("workshopTab")}
</Menu.Item>
<Menu.Divider />
<Menu.Item disabled={busy} leftSection={<IconCopy size={16} />} onClick={() => void run(async () => {
await fork.mutateAsync({ id: entry.id, name: t("forkDefaultName", { name: entry.name }).slice(0, 128) });
})}>
{t("fork")}
</Menu.Item>
<Menu.Item leftSection={<IconDownload size={16} />} onClick={() => void run(async () => {
const draft = await utils.customWidget.package.get.fetch({ id: entry.id });
downloadPackage(entry.name, draft.source);
})}>
{t("exportDraft")}
</Menu.Item>
{entry.activeArtifactId && <Menu.Item leftSection={<IconDownload size={16} />} onClick={() => void run(async () => {
downloadPackage(entry.name, await utils.customWidget.package.export.fetch({ id: entry.id }));
})}>{t("exportActive")}</Menu.Item>}
<Menu.Divider />
<Menu.Item
disabled={busy || !entry.activeArtifactId}
leftSection={entry.enabled ? <IconPlayerPause size={16} /> : <IconPlayerPlay size={16} />}
onClick={() => void run(() => enable.mutateAsync({ id: entry.id, enabled: !entry.enabled }))}
>
{entry.enabled ? t("disable") : t("enable")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,29 @@
"use client";
import { Alert, Badge, Button, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
export function PackageRuntimeStatus({ id }: { id: string }) {
const t = useI18n("customWidget.package.runtime");
const status = clientApi.customWidget.package.runtimeStatus.useQuery({ id });
const data = status.data;
return (
<Stack gap="xs">
<Text size="sm" fw={600}>{t("title")}</Text>
<Text size="xs" c="dimmed">{t("description")}</Text>
<Group>
{data && <Badge variant="light">{t(data.state)}</Badge>}
<Button variant="subtle" size="xs" loading={status.isFetching} onClick={() => void status.refetch()}>{t("refresh")}</Button>
</Group>
{data && "pid" in data && (
<Text size="xs">{t("process", { pid: data.pid ?? "—", active: data.activeRequests, queued: data.queuedRequests })}</Text>
)}
{data && "error" in data && <Alert color="red">{data.error}</Alert>}
{status.error && <Alert color="red">{status.error.message}</Alert>}
<Button component={Link} href="/manage/tools/logs" target="_blank" rel="noopener noreferrer" variant="default" size="xs">{t("logs")}</Button>
</Stack>
);
}
@@ -1,13 +1,14 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { Button, Group, NavLink, Select, Stack, Tabs, Text, TextInput, Alert } from "@mantine/core";
import { IconCode, IconPlus, IconTrash } from "@tabler/icons-react";
import { Button, Group, Select, Stack, Tabs, Text, TextInput, Alert, Collapse } from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { widgetReferencePackages } from "@homarr/widget-sdk/examples";
import { documentFromTemplate } from "./_package-document";
import type { usePackageWorkspace } from "./_use-package-workspace";
import classes from "./_package-workspace.module.css";
import { PackageFileTree } from "./_package-file-tree";
const PackageLanguageEditor = dynamic(() => import("./_package-language-editor"), { ssr: false });
export function PackageSourceEditor({
state,
@@ -17,7 +18,8 @@ export function PackageSourceEditor({
existing: boolean;
}) {
const t = useI18n("customWidget.package");
const [filesOpen, setFilesOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(true);
const [manageFiles, setManageFiles] = useState(false);
const { document, edit, selected, setSelected, path, setPath, busy, addFile, renameFile, removeFile, parsed } = state;
return (
<Tabs.Panel value="source" pt="md">
@@ -25,6 +27,7 @@ export function PackageSourceEditor({
{!existing && (
<Select
label={t("starter")}
description={t("starterDescription")}
placeholder={t("chooseStarter")}
data={Object.entries(widgetReferencePackages).map(([key, template]) => ({
value: key,
@@ -36,7 +39,7 @@ export function PackageSourceEditor({
const template = Object.entries(widgetReferencePackages).find(([key]) => key === starter)?.[1];
if (!template) return;
edit(documentFromTemplate(document.name, template));
setSelected("/widget.json");
setSelected(template.manifest.entrypoints.tile);
}}
/>
)}
@@ -63,59 +66,50 @@ export function PackageSourceEditor({
<div className={classes.editor} data-package-document data-files-open={filesOpen}>
{filesOpen && (
<Stack gap="xs" className={classes.files}>
<NavLink
label={t("manifestFile")}
active={selected === "/widget.json"}
onClick={() => setSelected("/widget.json")}
leftSection={<IconCode size={15} />}
/>
{Object.keys(document.files)
.toSorted()
.map((file) => (
<NavLink
key={file}
label={file}
active={selected === file}
onClick={() => setSelected(file)}
styles={{ label: { overflowWrap: "anywhere" } }}
/>
))}
<TextInput
aria-label={t("filePath")}
placeholder={t("filePathExample")}
value={path}
onChange={(event) => setPath(event.currentTarget.value)}
disabled={busy}
/>
<Group gap={4}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
onClick={addFile}
disabled={busy || !path.trim()}
>
{t("addFile")}
</Button>
<Button
size="compact-xs"
variant="subtle"
onClick={renameFile}
disabled={busy || !path.trim() || selected === "/widget.json"}
>
{t("renameFile")}
</Button>
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<IconTrash size={12} />}
onClick={removeFile}
disabled={busy || selected === "/widget.json"}
>
{t("removeFile")}
</Button>
</Group>
<PackageFileTree files={document.files} selected={selected} onSelect={setSelected} />
<Button size="compact-xs" variant="subtle" onClick={() => setManageFiles((value) => !value)} aria-expanded={manageFiles}>
{t("manageFiles")}
</Button>
<Collapse in={manageFiles}>
<Stack gap="xs">
<TextInput
aria-label={t("filePath")}
placeholder={t("filePathExample")}
value={path}
onChange={(event) => setPath(event.currentTarget.value)}
disabled={busy}
/>
<Group gap={4}>
<Button
size="compact-xs"
variant="subtle"
leftSection={<IconPlus size={12} />}
onClick={addFile}
disabled={busy || !path.trim()}
>
{t("addFile")}
</Button>
<Button
size="compact-xs"
variant="subtle"
onClick={renameFile}
disabled={busy || !path.trim() || selected === "/widget.json"}
>
{t("renameFile")}
</Button>
<Button
size="compact-xs"
color="red"
variant="subtle"
leftSection={<IconTrash size={12} />}
onClick={removeFile}
disabled={busy || selected === "/widget.json"}
>
{t("removeFile")}
</Button>
</Group>
</Stack>
</Collapse>
</Stack>
)}
<PackageLanguageEditor state={state} />
@@ -29,11 +29,13 @@
min-width: 0;
}
.editor[data-files-open="true"] {
grid-template-columns: 140px minmax(0, 1fr);
grid-template-columns: 190px minmax(0, 1fr);
}
.files {
overflow: auto;
max-height: 65vh;
border-right: 1px solid var(--mantine-color-default-border);
padding-right: var(--mantine-spacing-xs);
}
.preview {
position: sticky;
@@ -1,4 +1,6 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import {
Alert,
Badge,
@@ -11,10 +13,12 @@ import {
TextInput,
Tooltip,
ActionIcon,
Paper,
} from "@mantine/core";
import { IconArrowBackUp, IconArrowForwardUp, IconInfoCircle } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { PackageOptionFields } from "@homarr/widgets/custom-api/package-option-fields";
import { PackagePreview } from "./_package-preview";
import { PackageSplit } from "./_package-split";
import { PackageAssistant } from "./_package-assistant";
@@ -22,6 +26,8 @@ import { usePackageAssistant } from "./_use-package-assistant";
import { PackageSourceEditor } from "./_package-source-editor";
import { PackageConnections } from "./_package-connections";
import { PackageOperations } from "./_package-operations";
import { PackageWorkshop } from "./_package-workshop";
import { PackageTransfer } from "./_package-transfer";
import { usePackageWorkspace } from "./_use-package-workspace";
import type { Installation } from "./_use-package-workspace";
import classes from "./_package-workspace.module.css";
@@ -41,6 +47,8 @@ export function PackageWorkspace({
initialBoardId?: string;
}) {
const t = useI18n("customWidget.package");
const searchParams = useSearchParams();
const [tab, setTab] = useState(searchParams.get("tab") ?? "source");
const workspace = usePackageWorkspace(
installation,
userId,
@@ -73,6 +81,13 @@ export function PackageWorkspace({
saveLocalBindings,
} = workspace;
usePackageAssistant(workspace);
const current = workspace.installation;
let status = t("draftOnly");
if (current?.activeArtifactId) status = t("disabled");
if (current?.activeArtifactId && current.enabled) status = t("active");
let draftStatus = t("saved");
if (editor.dirty) draftStatus = t("unsaved");
if (!workspace.installationId) draftStatus = t("notSaved");
return (
<Stack
@@ -100,16 +115,30 @@ export function PackageWorkspace({
<Button variant="subtle" component={Link} href="/manage/custom-widgets/packages">
{t("library")}
</Button>
<Badge variant="light">{editor.dirty ? t("unsaved") : t("saved")}</Badge>
<Badge variant="light">{status}</Badge>
<Text size="xs" c="dimmed">{draftStatus}</Text>
<Button
variant="default"
loading={save.isPending}
disabled={!document.name.trim() || previewMutation.isPending}
disabled={!document.name.trim() || previewMutation.isPending || saveBindings.isPending}
onClick={() => void saveDraft()}
>
{t("saveDraft")}
</Button>
<Button onClick={() => { setTab("installation"); setPane("edit"); }}>
{t("installation")}
</Button>
</Group>
</Group>
{!workspace.installationId && (
<Paper withBorder p="md">
<Stack gap="xs">
<Text fw={600}>{t("gettingStarted")}</Text>
<Text size="sm" c="dimmed">{t("gettingStartedDescription")}</Text>
<Text size="sm">{t("manualSteps")}</Text>
</Stack>
</Paper>
)}
{error && (
<Alert color="red" withCloseButton onClose={() => setError("")}>
{error}
@@ -120,6 +149,16 @@ export function PackageWorkspace({
{message}
</Alert>
)}
{workspace.bindingsDirty && (
<Alert color="yellow">
<Group justify="space-between">
<Text size="sm">{t("connectionsUnsaved")}</Text>
<Button size="xs" loading={saveBindings.isPending || save.isPending} onClick={saveLocalBindings}>
{t("saveBindings")}
</Button>
</Group>
</Alert>
)}
{editor.recovery && (
<Alert color="yellow">
<Stack gap="xs">
@@ -176,16 +215,18 @@ export function PackageWorkspace({
<PackageSplit
userId={userId}
pane={pane}
preview={<PackagePreview state={workspace} existing={Boolean(installation)} />}
preview={<PackagePreview state={workspace} />}
>
<Stack className={classes.authoring}>
<Tabs defaultValue="source">
<Tabs value={tab} onChange={(value) => { if (value) setTab(value); }} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="source">{t("code")}</Tabs.Tab>
<Tabs.Tab value="connections">{t("connections")}</Tabs.Tab>
<Tabs.Tab value="options">{t("options")}</Tabs.Tab>
<Tabs.Tab value="installation">{t("installation")}</Tabs.Tab>
<Tabs.Tab value="workshop">{t("workshopTab")}</Tabs.Tab>
</Tabs.List>
<PackageSourceEditor state={workspace} existing={Boolean(installation)} />
<PackageSourceEditor state={workspace} existing={Boolean(workspace.installationId)} />
<Tabs.Panel value="connections" pt="md">
<PackageConnections
requirements={parsed.success ? parsed.data.connections : {}}
@@ -195,25 +236,42 @@ export function PackageWorkspace({
saving={saveBindings.isPending}
onConnectionSaved={workspace.connectionChanged}
/>
{!installation && (
<Text size="sm" c="dimmed">
{t("saveFirst")}
</Text>
)}
</Tabs.Panel>
<Tabs.Panel value="options" pt="md">
<Stack>
<Text size="sm" c="dimmed">{t("optionsDescription")}</Text>
{parsed.success && <PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />}
{parsed.success && Object.keys(parsed.data.options).length === 0 && <Alert>{t("noOptions")}</Alert>}
<Button variant="subtle" onClick={() => setOptions({})}>{t("resetOptions")}</Button>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="installation" pt="md">
<PackageOperations
installation={installation}
installation={current}
initialBoardId={initialBoardId}
source={parsed.success ? parsed.data : undefined}
options={effectiveOptions}
setOptions={setOptions}
dirty={editor.dirty}
trusted={trusted}
connectionsDirty={workspace.bindingsDirty}
onTrustChange={workspace.setTrusted}
onSave={() => void saveDraft()}
saving={save.isPending}
onError={setError}
onMessage={setMessage}
/>
</Tabs.Panel>
<Tabs.Panel value="workshop" pt="md">
<Stack>
<Text size="sm" c="dimmed">{t("workshopDescription")}</Text>
{current ? (
<>
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty} />
<PackageTransfer id={current.id} name={current.name} active={Boolean(current.activeArtifactId)} dirty={editor.dirty || workspace.bindingsDirty} onError={setError} />
</>
) : <Button onClick={() => void saveDraft()} loading={save.isPending}>{t("saveDraft")}</Button>}
</Stack>
</Tabs.Panel>
</Tabs>
</Stack>
</PackageSplit>
@@ -11,7 +11,12 @@ import type { PackageDocument } from "./_package-document";
const maximumHistoryBytes = 2_000_000;
const maximumRecoveryBytes = 1_000_000;
export function usePackageDocument(initial: PackageDocument, userId: string, installationId?: string) {
export function usePackageDocument(
initial: PackageDocument,
userId: string,
installationId?: string,
connectionsDirty = false,
) {
const [document, setDocument] = useState(initial);
const [saved, setSaved] = useState(initial);
const [recovery, setRecovery] = useState<PackageDocument | null>(null);
@@ -101,7 +106,7 @@ export function usePackageDocument(initial: PackageDocument, userId: string, ins
return () => clearTimeout(timer);
}, [definitionId, dirty, document, key, recovery]);
useUnsavedChangesGuard(dirty);
useUnsavedChangesGuard(dirty || connectionsDirty, { navigationKey: installationId });
const discardRecovery = () => {
setRecovery(null);
@@ -6,6 +6,7 @@ import { getCustomWidgetDefaultOptions } from "@homarr/custom-widgets/core";
import { customWidgetPackageSchema, widgetPackagePathSchema } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
import clock from "@homarr/widget-sdk/examples/clock.json";
import { getLogicalTrackSize } from "~/components/board/layout/geometry";
import { documentFromSource, documentFromTemplate, documentSource } from "./_package-document";
import { usePackageDocument } from "./_use-package-document";
export type Installation = RouterOutputs["customWidget"]["package"]["get"];
@@ -24,7 +25,12 @@ export function usePackageWorkspace(
if (installation) return documentFromSource(installation.name, installation.source);
return documentFromTemplate(initialName ?? t("newName"), initialSource ?? clock);
});
const editor = usePackageDocument(initial, userId, installation?.id);
const [installationId, setInstallationId] = useState(installation?.id);
const [bindings, setBindings] = useState(installation?.bindings ?? initialBindings ?? {});
const [savedBindings, setSavedBindings] = useState(bindings);
const bindingsDirty =
JSON.stringify(Object.entries(bindings).toSorted()) !== JSON.stringify(Object.entries(savedBindings).toSorted());
const editor = usePackageDocument(initial, userId, installationId, bindingsDirty);
const { document, edit } = editor;
const [selected, setSelected] = useState(Object.keys(initial.files)[0] ?? "/widget.json");
const [path, setPath] = useState("");
@@ -37,16 +43,19 @@ export function usePackageWorkspace(
const [liveActions, setLiveActions] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [bindings, setBindings] = useState(installation?.bindings ?? initialBindings ?? {});
const savedInstallationId = useRef(installation?.id);
const currentInstallation = clientApi.customWidget.package.get.useQuery(
{ id: installationId ?? "" },
{ enabled: Boolean(installationId), initialData: installation },
);
const [options, setOptions] = useState<Record<string, unknown>>({});
const [preview, setPreview] = useState<Preview | null>(null);
const [previewExecutionKey, setPreviewExecutionKey] = useState("");
const [stale, setStale] = useState(false);
const [surface, setSurface] = useState<"tile" | "advanced" | "configuration">("tile");
const [width, setWidth] = useState(320);
const [height, setHeight] = useState(260);
const [scale, setScale] = useState(1);
const [gridSize, setGridSize] = useState({ width: 2, height: 2 });
const [width, setWidth] = useState(getLogicalTrackSize(2));
const [height, setHeight] = useState(getLogicalTrackSize(2));
const [scale, setScale] = useState(0.9);
const [previewTheme, setPreviewTheme] = useState<"system" | "light" | "dark">("system");
const previewId = useRef<string | null>(null);
const retiredPreviewIds = useRef(new Set<string>());
@@ -93,11 +102,21 @@ export function usePackageWorkspace(
setMessage("");
const submitted = document;
try {
const result = await save.mutateAsync({ id: savedInstallationId.current, name: document.name, source });
savedInstallationId.current = result.id;
if (!installation && Object.keys(bindings).length > 0)
await saveBindings.mutateAsync({ id: result.id, bindings });
const result = await save.mutateAsync({ id: installationId, name: document.name, source });
setInstallationId(result.id);
editor.markSaved(submitted);
// Stay in the editor: a full navigation can trigger the unsaved-changes guard
// and discards selection, trust and the current preview after a successful save.
if (!installationId) {
const destination = new URL(window.location.href);
destination.pathname = destination.pathname.replace(/\/packages\/.*$/u, `/packages/${result.id}`);
if (initialBoardId) destination.searchParams.set("boardId", initialBoardId);
window.history.replaceState(null, "", destination.pathname + destination.search);
}
if (!installationId && Object.keys(bindings).length > 0) {
await saveBindings.mutateAsync({ id: result.id, bindings });
setSavedBindings(bindings);
}
await Promise.all([
utils.customWidget.package.get.invalidate(),
utils.customWidget.package.list.invalidate(),
@@ -105,24 +124,21 @@ export function usePackageWorkspace(
utils.customWidget.package.inspectArtifact.invalidate(),
]);
setMessage(t("draftSaved"));
if (!installation) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const destination = new URL(result.managementPath, window.location.origin);
if (initialBoardId) destination.searchParams.set("boardId", initialBoardId);
window.location.assign(destination.pathname + destination.search);
}
return result.id;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
};
const runPreview = async () => {
if (!installation || !trusted || !parsed.success) return;
if (!trusted || !parsed.success || busy) return;
const id = installationId ?? (await saveDraft());
if (!id) return;
resetExecution();
setError("");
const currentGeneration = generation.current;
try {
const next = await previewMutation.mutateAsync({
id: installation.id,
id,
source,
options: effectiveOptions,
bindings,
@@ -193,11 +209,15 @@ export function usePackageWorkspace(
if (parsed.success && parsed.data.manifest.entrypoints.configuration)
surfaceChoices.push({ value: "configuration", label: t("configuration") });
const saveLocalBindings = () => {
if (!installation) return;
if (!installationId) {
void saveDraft();
return;
}
saveBindings.mutate(
{ id: installation.id, bindings },
{ id: installationId, bindings },
{
onSuccess: () => {
setSavedBindings(bindings);
setMessage(t("bindingsSaved"));
void utils.customWidget.package.get.invalidate();
void utils.customWidget.package.guestGrants.invalidate();
@@ -222,7 +242,8 @@ export function usePackageWorkspace(
};
return {
installationId: savedInstallationId.current,
installationId,
installation: currentInstallation.data,
editor,
document,
edit,
@@ -243,6 +264,7 @@ export function usePackageWorkspace(
message,
setMessage,
bindings,
bindingsDirty,
setBindings,
options,
setOptions,
@@ -256,6 +278,12 @@ export function usePackageWorkspace(
setWidth,
height,
setHeight,
gridSize,
setGridSize: (size: { width: number; height: number }) => {
setGridSize(size);
setWidth(getLogicalTrackSize(size.width));
setHeight(getLogicalTrackSize(size.height));
},
scale,
setScale,
previewTheme,
@@ -9,6 +9,8 @@ import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
interface UnsavedChangesGuardOptions {
guardBeforeUnload?: boolean;
/** Reset the remembered URL when an unsaved resource gets its first saved identity. */
navigationKey?: string;
}
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
@@ -45,6 +47,6 @@ export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChanges
confirmNavigation: (href) => confirmNavigationRef.current(href),
guardBeforeUnload: options.guardBeforeUnload,
}),
[options.guardBeforeUnload],
[options.guardBeforeUnload, options.navigationKey],
);
}
@@ -7,8 +7,18 @@ import { assertWidgetArtifactIntegrity } from "@homarr/custom-widgets/package/se
import { getArtifact, getInstallation, readPackageDraft } from "./records";
import { widgetPackageAdminProcedure } from "./procedure";
import { getWidgetPackageSupervisor } from "./invocations";
export const packageArtifactReviewProcedures = {
runtimeStatus: widgetPackageAdminProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const installation = await getInstallation(ctx, input.id);
if (!installation.activeArtifactId) return { state: "unactivated" as const };
const { artifact } = await getArtifact(ctx, installation.activeArtifactId);
if (!artifact.server) return { state: "browserOnly" as const };
return getWidgetPackageSupervisor().inspect(artifact.digest);
}),
inspectArtifact: widgetPackageAdminProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
@@ -42,10 +42,13 @@ export async function getBoundConnection(ctx: PackageContext, bindings: Record<s
const row = await ctx.db.query.customWidgetConnections.findFirst({ where: eq(customWidgetConnections.id, id) });
if (!row) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Bound connection is unavailable" });
const configuration = connectionConfigurationSchema.parse(JSON.parse(row.configuration));
const integration = row.integrationId
? await ctx.db.query.integrations.findFirst({ where: eq(integrations.id, row.integrationId) })
: undefined;
const secrets = z
.record(z.string(), z.string())
.parse(JSON.parse(decryptSecret(row.encryptedSecrets as `${string}.${string}`)));
return { row, configuration, secrets };
return { row, configuration: { ...configuration, integrationKind: integration?.kind }, secrets };
}
export async function getBindingsDigest(ctx: PackageContext, bindings: Record<string, string>) {
@@ -81,9 +84,14 @@ const admin = widgetPackageAdminProcedure;
export const packageConnectionProcedures = {
connections: admin.query(async ({ ctx }) => {
const rows = await ctx.db.query.customWidgetConnections.findMany();
const nativeIntegrations = await ctx.db.query.integrations.findMany({ columns: { id: true, kind: true } });
const kinds = new Map(nativeIntegrations.map(({ id, kind }) => [id, kind]));
return rows.map(({ encryptedSecrets: _secrets, ...row }) => ({
...row,
configuration: JSON.parse(row.configuration) as unknown,
configuration: {
...connectionConfigurationSchema.parse(JSON.parse(row.configuration)),
integrationKind: row.integrationId ? kinds.get(row.integrationId) : undefined,
},
}));
}),
saveConnection: admin
@@ -1,4 +1,4 @@
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { widgetPackageAdminProcedure } from "./procedure";
import { TRPCError } from "@trpc/server";
import { nativeScaffoldProcedures } from "./native-scaffold";
@@ -187,7 +187,7 @@ export const customWidgetPackageRouter = createTRPCRouter({
const draft = customWidgetPackageSchema.safeParse(JSON.parse(installation.draft));
for (const [name] of Object.entries(input.bindings)) {
const connection = await getBoundConnection(ctx, input.bindings, name);
const requirement = draft.success ? draft.data.connections[name] : undefined;
const requirement = draft.success ? getWidgetConnectionRequirement(draft.data.connections, name) : undefined;
if (requirement && !isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
@@ -1,4 +1,4 @@
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { TRPCError } from "@trpc/server";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
@@ -60,13 +60,17 @@ export async function prepareWidgetMigration(
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Placement ${placement.id}: ${issues[0]?.message}` });
const mergedBindings = { ...bindings, ...placement.packageOptions.connectionBindings };
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.optional && !mergedBindings[name]) continue;
const connection = await getBoundConnection(ctx, mergedBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
const names = getWidgetConnectionBindingNames(name, requirement, mergedBindings);
if (!names.length && !requirement.optional)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Connection '${name}' needs setup` });
for (const bindingName of names) {
const connection = await getBoundConnection(ctx, mergedBindings, bindingName);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
}
}
const raw: unknown = parse(placement.options);
if (!isRecord(raw)) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Placement options are invalid" });
@@ -59,29 +59,34 @@ export const nativeScaffoldProcedures = {
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.kind !== "integration") continue;
const accepted = acceptedIntegrationKinds(input.starter, name);
const integration = bound.find(
const matches = bound.filter(
(candidate) => accepted.includes(candidate.kind) && !usedIntegrationIds.has(candidate.id),
);
if (!integration) continue;
usedIntegrationIds.add(integration.id);
let connection = await ctx.db.query.customWidgetConnections.findFirst({
where: eq(customWidgetConnections.integrationId, integration.id),
});
if (!connection) {
const id = createId();
const row = {
id,
name: integration.name,
integrationId: integration.id,
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
encryptedSecrets: encryptSecret("{}"),
createdAt: new Date(),
updatedAt: new Date(),
};
await ctx.db.insert(customWidgetConnections).values(row);
connection = row;
let candidates = matches;
if (!requirement.multiple) candidates = matches.slice(0, 1);
for (const [index, integration] of candidates.entries()) {
usedIntegrationIds.add(integration.id);
let connection = await ctx.db.query.customWidgetConnections.findFirst({
where: eq(customWidgetConnections.integrationId, integration.id),
});
if (!connection) {
const id = createId();
const row = {
id,
name: integration.name,
integrationId: integration.id,
configuration: JSON.stringify(connectionConfigurationSchema.parse({ kind: "integration" })),
encryptedSecrets: encryptSecret("{}"),
createdAt: new Date(),
updatedAt: new Date(),
};
await ctx.db.insert(customWidgetConnections).values(row);
connection = row;
}
let bindingName = name;
if (index > 0) bindingName = `${name}:${connection.id}`;
bindings[bindingName] = connection.id;
}
bindings[name] = connection.id;
}
return { name: `${source.manifest.name} (custom)`, source, bindings, sourceBoardId: item.boardId };
}),
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetInstallations } from "@homarr/db/schema";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { publishWidgetPackageChange } from "./events";
import { getWidgetCollection } from "./portable-collection-records";
@@ -34,7 +34,11 @@ export async function setWidgetCollectionBindings(
message: `Widget '${entry.key}' has an invalid source draft. Repair it first.`,
});
const nextBindings = { ...entry.bindings };
for (const name of Object.keys(entry.mapping)) delete nextBindings[name];
for (const name of Object.keys(entry.mapping)) {
for (const key of Object.keys(nextBindings)) {
if (key === name || key.startsWith(`${name}:`)) delete nextBindings[key];
}
}
Object.assign(nextBindings, mapCollectionBindings(entry.mapping, bindings));
for (const name of Object.keys(entry.mapping)) {
const requirement = entry.requirements[name];
@@ -43,13 +47,14 @@ export async function setWidgetCollectionBindings(
code: "PRECONDITION_FAILED",
message: `Widget '${entry.key}' no longer declares connection '${name}'. Review its package mapping.`,
});
if (!nextBindings[name]) continue;
const connection = await getBoundConnection(ctx, nextBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
});
for (const bindingName of getWidgetConnectionBindingNames(name, requirement, nextBindings)) {
const connection = await getBoundConnection(ctx, nextBindings, bindingName);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "BAD_REQUEST",
message: `Widget '${entry.key}' connection '${name}' is incompatible with the selected shared connection`,
});
}
}
changes.push({
id: entry.id,
@@ -78,7 +78,11 @@ export async function getWidgetCollection(ctx: PackageContext, importId: string)
for (const entry of entries) {
for (const [name, slot] of Object.entries(entry.mapping)) {
const bindings = values.get(slot) ?? new Set<string>();
bindings.add(entry.bindings[name] ?? "");
const members = Object.entries(entry.bindings)
.filter(([binding]) => binding === name || binding.startsWith(`${name}:`))
.map(([binding, id]) => [binding.slice(name.length), id])
.toSorted(([left], [right]) => (left ?? "").localeCompare(right ?? ""));
bindings.add(JSON.stringify(members));
values.set(slot, bindings);
}
}
@@ -87,8 +91,8 @@ export async function getWidgetCollection(ctx: PackageContext, importId: string)
for (const [slot, ids] of values) {
if (ids.size > 1) bindingConflicts.push(slot);
else {
const id = [...ids][0];
if (id) bindings[slot] = id;
const members = JSON.parse([...ids][0] ?? "[]") as [string, string][];
for (const [suffix, id] of members) bindings[`${slot}${suffix}`] = id;
}
}
return {
@@ -8,7 +8,7 @@ import type {
WidgetCollectionContents,
WidgetCollectionManifest,
} from "@homarr/custom-widgets/package";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { getWidgetConnectionBindingNames, getWidgetConnectionRequirement, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { createWidgetCollectionArchive, validateWidgetCollectionArchive } from "@homarr/custom-widgets/package/server";
import { withWidgetArtifactReferenceLock } from "./artifact-references";
@@ -37,7 +37,7 @@ export async function validateCollectionBindings(
bindings: Record<string, string>,
) {
for (const [name] of Object.entries(bindings)) {
const requirement = collection.connections[name];
const requirement = getWidgetConnectionRequirement(collection.connections, name);
if (!requirement) throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown collection connection '${name}'` });
try {
const connection = await getBoundConnection(ctx, bindings, name);
@@ -55,9 +55,9 @@ export async function validateCollectionBindings(
export function mapCollectionBindings(mapping: Record<string, string>, bindings: Record<string, string>) {
return Object.fromEntries(
Object.entries(mapping).flatMap(([name, slot]) => {
const id = bindings[slot];
if (!id) return [];
return [[name, id]];
return Object.entries(bindings)
.filter(([binding]) => binding === slot || binding.startsWith(`${slot}:`))
.map(([binding, id]) => [`${name}${binding.slice(slot.length)}`, id]);
}),
);
}
@@ -145,7 +145,8 @@ export async function exportWidgetCollection(ctx: PackageContext, manifest: Widg
const bindings = parseBindings(row.bindings);
const mapping: Record<string, string> = {};
for (const [name, requirement] of Object.entries(source.connections)) {
const identity = `${requirement.kind}:${requirement.serviceType ?? ""}:${bindings[name] ?? `${id}:${name}`}`;
const members = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]).toSorted();
const identity = JSON.stringify([requirement.kind, requirement.serviceType, requirement.integrationKind, requirement.multiple, members.length ? members : [id, name]]);
let slot = shared.get(identity);
if (!slot) {
slot = `connection${shared.size + 1}`;
@@ -6,7 +6,7 @@ import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common/server";
import { createId } from "@homarr/common";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import { customWidgetConnections, groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { WidgetSdkInvocation } from "@homarr/custom-widgets/package/server";
import { readPackageHistory, widgetHistoryInputSchema } from "./history";
@@ -56,13 +56,24 @@ export function createWidgetSdkBridge(
}
signal?.throwIfAborted();
if (operation === "legacy.request") return invokeLegacyWidgetRequest(ctx, resolved, kind, input, signal);
if (operation === "widget.context")
if (operation === "widget.context") {
const ids = Object.values(resolved.bindings);
let labels = new Map<string, string>();
if (ids.length) {
const rows = await ctx.db.query.customWidgetConnections.findMany({
where: inArray(customWidgetConnections.id, ids),
columns: { id: true, name: true },
});
labels = new Map(rows.map((row) => [row.id, row.name]));
}
return {
options: resolved.configuration,
bindingNames: Object.keys(resolved.bindings),
bindingLabels: Object.fromEntries(Object.entries(resolved.bindings).map(([name, id]) => [name, labels.get(id) ?? name])),
installationId: resolved.installation.id,
isPreview: Boolean(resolved.preview),
};
}
if (operation === "integration.next" || operation === "integration.unsubscribe") {
const { id } = z.object({ id: z.string() }).parse(input);
const iterator = subscriptions.get(id);
@@ -90,6 +90,8 @@ export const widgetCollectionContentsSchema = z
else if (!shared) issue(["connections", name], `collection slot '${slot}' is undeclared`);
else if (!isWidgetConnectionCompatible(requirement, shared))
issue(["connections", name], `slot '${slot}' requires compatible ${requirement.kind} settings`);
else if (Boolean(requirement.multiple) !== Boolean(shared.multiple))
issue(["connections", name], `slot '${slot}' must use the same repeatable connection setting`);
else if (!requirement.optional && shared.optional)
issue(["connections", name], `slot '${slot}' must be required because this widget requires it`);
}
@@ -76,6 +76,17 @@ export class WidgetPackageProcess {
public get stopped() {
return this.dead;
}
public inspect() {
let state: "running" | "stopped" = "running";
if (this.dead) state = "stopped";
return {
state,
pid: this.child.pid ?? null,
activeRequests: this.pending.size,
queuedRequests: this.queued,
lastUsed: this.lastUsed,
};
}
public waitUntilReady() {
return this.ready;
}
+32 -2
View File
@@ -79,6 +79,9 @@ export const widgetPackageConnectionSchema = z
label: z.string().trim().min(1).max(128),
kind: z.enum(["http", "integration", "service"]),
serviceType: identifier.optional(),
integrationKind: identifier.optional(),
// Optional fields preserve the digest of existing package archives.
multiple: z.boolean().optional(),
optional: z.boolean().default(false),
description: z.string().max(2048).optional(),
})
@@ -89,14 +92,41 @@ export const widgetPackageConnectionSchema = z
/** Shared by binding pickers, activation and portable collection imports. */
export function isWidgetConnectionCompatible(
requirement: { kind: string; serviceType?: string },
configuration: { kind?: unknown; serviceType?: unknown },
requirement: { kind: string; serviceType?: string; integrationKind?: string },
configuration: { kind?: unknown; serviceType?: unknown; integrationKind?: unknown },
) {
if (requirement.kind !== configuration.kind) return false;
if (requirement.kind === "service") return requirement.serviceType === configuration.serviceType;
if (requirement.kind === "integration" && requirement.integrationKind)
return requirement.integrationKind === configuration.integrationKind;
return true;
}
/** Repeatable connections retain the original named binding and add stable, namespaced members.
* A colon cannot appear in a manifest connection name, so groups cannot overlap.
*/
export function getWidgetConnectionBindingNames(
name: string,
requirement: { multiple?: boolean },
bindings: Record<string, string>,
) {
return Object.keys(bindings).filter(
(key) => Boolean(bindings[key]) && (key === name || (requirement.multiple && key.startsWith(`${name}:`))),
);
}
export function getWidgetConnectionRequirement<T extends { multiple?: boolean }>(
requirements: Record<string, T>,
name: string,
) {
const exact = requirements[name];
if (exact) return exact;
const prefix = name.split(":")[0];
const group = prefix && requirements[prefix];
if (group && group.multiple) return group;
return undefined;
}
export const customWidgetPackageSchema = z
.strictObject({
$schema: z.literal(CUSTOM_WIDGET_PACKAGE_SCHEMA),
@@ -100,6 +100,17 @@ export class CustomWidgetPackageSupervisor {
if (pending) (await pending.catch(() => undefined))?.stop();
}
/** Read only: inspecting an inactive artifact must never start its server. */
public async inspect(digest: string) {
const pending = this.processes.get(digest);
if (!pending) return { state: "idle" as const };
try {
return (await pending).inspect();
} catch (error) {
return { state: "failed" as const, error: error instanceof Error ? error.message : String(error) };
}
}
public async cancelInstances(instanceIds: string[], options: { actions?: boolean } = {}): Promise<void> {
for (const id of instanceIds) this.instanceGenerations.set(id, (this.instanceGenerations.get(id) ?? 0) + 1);
const ids = new Set(instanceIds);
+68 -17
View File
@@ -1196,7 +1196,7 @@
"library": "Packages",
"libraryDescription": "Build dashboard widgets with full TSX, CSS, modules, and optional server code. Import a package to inspect its source and connect your local services before activating it.",
"search": "Search packages",
"empty": "No matching packages. Create a source-free widget or import a package to get started.",
"empty": "No widget packages yet.",
"openWorkspace": "Open workspace",
"draftOnly": "Draft only",
"active": "Active",
@@ -1204,11 +1204,12 @@
"import": "Import package",
"importTooLarge": "Package exceeds the 25 MB import limit.",
"invalidPackage": "Choose a widget package or archive JSON file.",
"name": "Installation name",
"name": "Widget name",
"unsaved": "Unsaved changes",
"saved": "Saved draft",
"notSaved": "Not saved yet",
"saveDraft": "Save draft",
"draftSaved": "Draft saved. Dashboard placements still use the active release.",
"draftSaved": "Draft saved. Use on dashboard lets you activate this version and add it to a board. Existing tiles keep using their active version until then.",
"recovery": "A recovery draft is available for this package. Local connection credentials are stored separately; recognized credential literals are removed from recovery source.",
"restore": "Restore draft",
"discard": "Discard recovery",
@@ -1218,9 +1219,9 @@
"historyDescription": "Undo/Redo covers the whole document, up to 100 changes and 2 MB. One browser recovery draft, up to 1 MB.",
"code": "Source files",
"connections": "Connections",
"installation": "Activation & boards",
"starter": "Reference starter",
"chooseStarter": "Choose a reference package",
"installation": "Use on dashboard",
"starter": "Start from an example",
"chooseStarter": "Choose a widget to customize",
"clock": "World clock · no API required",
"filePath": "Relative file path",
"addFile": "Add",
@@ -1231,13 +1232,13 @@
"renameImports": "File renamed. Update imports and manifest entrypoints that refer to its previous path.",
"draftInvalid": "This draft can be saved. Resolve these package errors before preview or activation.",
"reviewTrust": "Review code & dependencies",
"trustedCode": "This package runs trusted JavaScript in Homarr and may run Node code with Homarr's server privileges. Review every source file and dependency before running it. Preview also executes package code.",
"trustedCode": "Trusted browser code has access to your Homarr session and browser APIs. Server code runs with Homarr's operating-system authority, including available credentials, networks and mounted files. The separate process is not a security sandbox. Only run code and dependencies you trust; preview executes them too.",
"dependencies": "Additional dependencies",
"hostOnly": "Shared Homarr modules only",
"trustAcknowledgement": "I trust this source and its dependencies to run on my Homarr instance.",
"preview": "Run preview",
"exportDraft": "Export source draft",
"saveFirst": "Save this draft first to enable preview, save local bindings, and activate it.",
"saveFirst": "Save your draft, then activate it to make it available on dashboards.",
"surface": "Preview surface",
"tile": "Dashboard tile",
"advanced": "Advanced view",
@@ -1295,12 +1296,12 @@
},
"previewLiveSession": "Session: live SDK actions",
"previewSimulatedSession": "Session: simulated SDK actions",
"connectionsDescription": "Bind the package's named connections to services on this Homarr instance. Saving bindings updates every placement that inherits them; placements can override a connection in board settings.",
"connectionsDescription": "Choose existing Homarr integrations or create local API connections. No trust acknowledgement is needed to configure them. Save connections to apply them to tiles that inherit this setup.",
"noConnections": "This package needs no API or service connection.",
"diagnose": "Diagnose",
"newConnection": "Create local connection",
"saveBindings": "Save installation bindings",
"bindingsSaved": "Installation bindings saved.",
"saveBindings": "Save connections",
"bindingsSaved": "Connections saved for tiles that inherit this setup.",
"connectionKind": "Connection type",
"integration": "Homarr integration",
"serverUrl": "Server URL",
@@ -1316,13 +1317,13 @@
"stringMap": "Enter a JSON object whose values are strings.",
"previewOptions": "Preview options",
"resetOptions": "Reset preview options to package defaults",
"activation": "Activation",
"activation": "Make this version available on dashboards",
"activationDescription": "Activate the saved draft for all {count} dashboard placements of this installation. Existing placements keep their option values. A failed activation keeps the current release running.",
"activeArtifact": "Active artifact",
"none": "None",
"saveBeforeActivate": "Save your changes before activating. Activation always uses the saved draft.",
"activate": "Activate saved draft",
"activated": "Saved package activated.",
"activate": "Activate for dashboards",
"activated": "This version is ready for dashboards. Add it to a board below, or open an existing placement.",
"disable": "Disable installation",
"enable": "Enable installation",
"stateSaved": "Installation state saved.",
@@ -1410,7 +1411,7 @@
"connectionName": "Connection name",
"assistant": {
"open": "Ask Assistant",
"context": "Uses this draft and public SDK context",
"context": "Assistant can read this open draft and propose edits for you to review. You can undo an applied proposal in one step.",
"request": "What would you like to build?",
"requestPlaceholder": "Describe a widget, a design change, or a capability…",
"send": "Propose changes",
@@ -1431,7 +1432,14 @@
"preparing": "Preparing proposed changes…",
"invalidProposal": "This proposal is incomplete or invalid.",
"reading": "Reading the open package draft…",
"read": "Read the open package draft"
"read": "Read the open package draft",
"unavailable": "Assistant is not configured or is unavailable for your account.",
"setup": "Open Assistant setup",
"manual": "You can still choose an example, edit its files and preview it without Assistant.",
"busy": "Assistant is already responding. Wait for it to finish before sending another request.",
"sendFailed": "The request could not be sent. Check the Assistant panel and try again.",
"create": "Build a widget with me",
"createPrompt": "Help me create a widget package. Ask what app or idea I want to build, then propose an approachable first version using the available SDK. Explain any connection setup. Do not assume I want to keep the current example."
},
"sessionUnavailable": "Preview session unavailable",
"sessionUnavailableDescription": "The session expired, was discarded, or belongs to another account. Sign in as the API key owner and create a fresh CLI preview.",
@@ -1545,7 +1553,50 @@
"previewThemeDark": "Dark",
"previewScale": "Display scale",
"savedSourceChanged": "The saved source changed. Reload and review it before activating.",
"previewPane": "Preview"
"previewPane": "Preview",
"starterDescription": "Pick a working example, then edit its files or ask Assistant for changes. Switching examples replaces this draft; Undo restores it.",
"gettingStarted": "Your widget, your way",
"gettingStartedDescription": "Packages support full React, TypeScript, CSS and optional Node code. Custom JSX is the simpler template editor. Neither needs an API for a static widget.",
"manualSteps": "Choose an example → connect services if needed → edit files and options → preview → use on dashboard. Assistant is optional.",
"manageFiles": "Add, rename or remove files",
"optionsDescription": "Adjust the preview without editing code. These values also start the setup for your next dashboard tile. Existing tiles keep their own settings.",
"noOptions": "This example has no configurable options. Edit its files to customize it.",
"useDescription": "Save a draft to keep your work. Activate it when you want boards to use that code, then add it to a board below. One package can appear on several boards.",
"previewSavesFirst": "Your first preview also saves a draft. It does not add or change dashboard tiles.",
"workshopTab": "Workshop",
"workshopDescription": "Share your finished widget, publish a new release, or review upstream updates. Local connections and credentials stay on your Homarr instance.",
"releaseTools": "Release details, disable and rollback",
"chooseConnection": "Choose a connection or Homarr integration",
"noCompatibleConnections": "No compatible connections yet. Create one below, then refresh this list.",
"unavailableConnection": "Connection unavailable — choose a replacement",
"createIntegration": "Create Homarr integration ↗",
"connectionsUnsaved": "Connection changes are not saved yet. Save connections before using them on dashboards.",
"customViewport": "Custom viewport and scale",
"customViewportDescription": "Use the grid controls in the preview for normal board sizes. Pixel dimensions below override the grid for this preview only.",
"browserExecution": "React and CSS run in your browser, inside Homarr, with its theme and UI context.",
"serverExecution": "This package has a server entrypoint. Homarr starts a supervised Node process on demand when a handler is called and shares it between users of that artifact. Idle processes are released.",
"browserOnlyExecution": "This package has no server entrypoint, so it needs no separate Node process. Static widgets do not need a connection.",
"runtimeLogs": "Build and preview errors appear here. Server output is included in Homarr logs under custom-widget-package. The active release process can be inspected below.",
"buildState": "Preview build: {state}",
"actionsMenu": "Widget package actions",
"forkDefaultName": "{name} fork",
"previewBuilding": "Building preview…",
"previewBuildReady": "Preview build ready",
"previewBuildFailed": "Preview build failed",
"previewNotBuilt": "Preview not built yet",
"runtime": {
"title": "Active release process",
"description": "This is a snapshot from the Homarr replica serving this request. It does not start code or inspect a separate preview process. Refresh after opening a tile to see its current state.",
"unactivated": "No active release",
"browserOnly": "Browser only · no Node process",
"idle": "No process on this replica",
"running": "Running",
"stopped": "Stopped",
"failed": "Startup failed",
"refresh": "Refresh status",
"process": "PID {pid} · {active} active requests · {queued} queued",
"logs": "Open Homarr logs ↗"
}
}
},
"workshop": {
+17 -24
View File
@@ -2,10 +2,10 @@
"$schema": "homarr-widget-package-v3",
"manifest": {
"id": "homarr.reference.sonarr",
"version": "1.1.0",
"version": "1.2.0",
"sdkVersion": "1",
"name": "Sonarr operations",
"description": "Two-server Sonarr operations: upcoming episodes, missing releases, queue progress, filtered advanced views and confirmed episode search through the native adapter.",
"description": "Calendar, missing episodes and queue across any number of Sonarr integrations, with per-server search controls.",
"entrypoints": {
"tile": "widget.tsx",
"advanced": "widget.tsx",
@@ -21,11 +21,16 @@
"permission": "modify",
"inputSchema": {
"type": "object",
"required": ["connection", "episodeIds"],
"required": [
"connection",
"episodeIds"
],
"additionalProperties": false,
"properties": {
"connection": {
"enum": ["sonarr", "secondary"]
"type": "string",
"minLength": 1,
"maxLength": 256
},
"episodeIds": {
"type": "array",
@@ -42,21 +47,19 @@
}
},
"files": {
"widget.tsx": "import { Alert, Badge, Button, Group, Loader, Progress, SegmentedControl, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport type { Connection, Overview, SonarrOptions } from \"./types\";\nexport default function Sonarr() {\n const host = useWidgetHost(); const services = useWidgetServices(); const options = useWidgetOptions<SonarrOptions>();\n const [tab,setTab] = useWidgetState(\"tab\",\"calendar\");\n const [source,setSource] = useWidgetState(\"source\",\"all\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const query = useWidgetQuery<Overview>(\"overview\",{}, {refetchInterval:60000});\n const search = useWidgetAction<{id:number;status:string},{connection:Connection;episodeIds:number[]}>(\"searchEpisodes\");\n const label = (connection:Connection) => connection === \"sonarr\" ? options.primaryName : options.secondaryName;\n const sources = query.data?.sources ?? [];\n const rows = sources.filter((entry) => source === \"all\" || source === entry.connection).flatMap((entry) => {\n let items = entry.calendar;\n if (tab === \"missing\") items = entry.missing.items;\n if (tab === \"queue\") items = entry.queue.items;\n return items.map((item,index) => ({...item,connection:entry.connection,rowKey:`${entry.connection}:${tab}:${item.id ?? index}`}));\n }).filter((item) => `${item.title} ${item.subTitle ?? item.seriesTitle ?? \"\"}`.toLowerCase().includes(filter.toLowerCase()));\n const visible = host.displayMode === \"advanced\" ? rows : rows.slice(0,4);\n const requestSearch = async (connection:Connection,id:number,title:string) => {\n if (await services.confirm({title:\"Search for this episode?\",message:`Ask ${label(connection)} to find and download an eligible release for ${title}?`})) search.mutate({connection,episodeIds:[id]});\n };\n return <Stack h=\"100%\" p=\"md\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge>{sources.reduce((sum,entry) => sum+entry.queue.totalCount,0)} queued</Badge></Group>\n <SegmentedControl value={tab} onChange={setTab} data={[{label:\"Upcoming\",value:\"calendar\"},{label:\"Missing\",value:\"missing\"},{label:\"Queue\",value:\"queue\"}]} />\n {sources.length > 1 && <Select aria-label=\"Sonarr server\" value={source} onChange={(value) => setSource(value ?? \"all\")} data={[{value:\"all\",label:\"All servers\"},...sources.map((entry) => ({value:entry.connection,label:label(entry.connection)}))]} />}\n {host.displayMode === \"advanced\" && <TextInput aria-label=\"Filter episodes\" placeholder=\"Filter series or episode…\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />}\n {query.isPending && <Loader size=\"sm\" />}\n {query.error && <Alert color=\"red\">{query.error.message}</Alert>}\n {sources.filter((entry) => entry.error).map((entry) => <Alert key={entry.connection} color=\"red\" title={label(entry.connection)}>{entry.error}</Alert>)}\n {search.error && <Alert color=\"red\">{search.error.message}</Alert>}\n {search.data?.id && <Text size=\"xs\" c=\"teal\">Search command {search.data.id}: {search.data.status}. Sonarr will select an eligible release.</Text>}\n {visible.map((item) => <Stack key={item.rowKey} gap={4}><Group justify=\"space-between\"><Text fw={500} size=\"sm\">{item.subTitle ?? item.seriesTitle ?? item.title}</Text><Badge size=\"xs\" variant=\"light\">{label(item.connection)}</Badge></Group><Text size=\"xs\" c=\"dimmed\">{item.title}{item.seasonNumber !== undefined && ` · S${item.seasonNumber} E${item.episodeNumber ?? \"?\"}`}</Text>{item.startDate && <Text size=\"xs\">{new Date(item.startDate).toLocaleString(host.locale)}</Text>}{item.percentComplete !== undefined && <Progress value={item.percentComplete} aria-label={`${item.title} download progress`} />}<Group gap=\"xs\">{(item.link || item.links?.[0]?.href) && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.navigate(item.link ?? item.links?.[0]?.href ?? \"\")}>Open in Sonarr</Button>}{tab === \"missing\" && typeof item.id === \"number\" && <Button size=\"compact-xs\" disabled={host.isEditMode || search.isPending} onClick={() => void requestSearch(item.connection,item.id!,item.title)}>Search episode</Button>}</Group></Stack>)}\n {query.data && !rows.length && <Text c=\"dimmed\">No episodes match this view.</Text>}\n <Group><Button size=\"xs\" variant=\"light\" onClick={() => void query.refetch()}>Refresh</Button>{host.displayMode === \"compact\" && <Button size=\"xs\" variant=\"subtle\" onClick={services.openAdvanced}>All episodes and filters</Button>}</Group>\n </Stack>;\n}\n",
"server.ts": "import type { Connection, EpisodeRow, SearchInput, SonarrOptions } from \"./types\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nconst connections:Connection[] = [\"sonarr\", \"secondary\"];\nexport default defineWidgetServer({\n overview: async (_input, context) => {\n const {bindingNames,options} = await getWidgetServerContext<SonarrOptions>(context);\n const start = new Date();\n const end = new Date(start.getTime()+Math.max(1,Math.min(90,Number(options.days)||7))*86400000);\n const names = connections.filter((name) => bindingNames.includes(name));\n if (!names.length) throw new Error(\"Bind at least one Sonarr integration\");\n const sources = await Promise.all(names.map(async (connection) => {\n const call = <T,>(capability:string,input:unknown) => callWidgetIntegration<T>(context,{connection,capability,input});\n try {\n const [calendar,missing,queue] = await Promise.all([\n call<EpisodeRow[]>(\"sonarr.calendar\",{start:start.toISOString(),end:end.toISOString()}),\n call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.missing\",{limit:100}), call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.queue\",{limit:100}),\n ]);\n return {connection,calendar,missing,queue};\n } catch (error) { return {connection,calendar:[],missing:{items:[],totalCount:0},queue:{items:[],totalCount:0},error:error instanceof Error ? error.message : String(error)}; }\n }));\n return {sources};\n },\n searchEpisodes: (input:SearchInput,context) => {\n if (!connections.includes(input?.connection) || !Array.isArray(input?.episodeIds) || !input.episodeIds.length || input.episodeIds.length > 100 || input.episodeIds.some((id) => !Number.isInteger(id) || id < 1)) throw new Error(\"Choose valid episode IDs from a declared Sonarr server\");\n return callWidgetIntegration(context,{connection:input.connection,capability:\"sonarr.searchEpisodes\",input:{episodeIds:input.episodeIds}});\n },\n});\n",
"types.ts": "export type Connection = \"sonarr\" | \"secondary\";\nexport interface EpisodeRow { id?:number; title:string; subTitle?:string; seriesTitle?:string; startDate?:string; seasonNumber?:number; episodeNumber?:number; percentComplete?:number; status?:string; timeLeft?:string|null; link?:string; links?:{href:string}[]; }\nexport interface Source { connection:Connection; calendar:EpisodeRow[]; missing:{items:EpisodeRow[];totalCount:number}; queue:{items:EpisodeRow[];totalCount:number}; error?:string; }\nexport interface Overview { sources:Source[]; }\nexport type SonarrOptions = {title:string;primaryName:string;secondaryName:string;days:number};\nexport type SearchInput = {connection:Connection;episodeIds:number[]};\n",
"README.md": "# Sonarr operations\nBind the primary Sonarr integration and optionally a second one. Existing Homarr native query and interaction permissions apply. Each result retains its connection identity, including when different servers use the same numeric episode ID.\n\nCompact view shows four episodes; advanced view includes all loaded rows, source selection and text filtering. The reference reads up to 100 missing and queued episodes per server. A failure on one server does not hide the other server's result.\n\nSearch episode is a named modify action. It confirms the target server and episode before submitting the native Sonarr EpisodeSearch command. A queued command does not prove a release was found or downloaded. Default previews simulate actions. The integration sends the API key server-side; no key is embedded in portable source.\n\nAPI reference: https://github.com/Sonarr/Sonarr/blob/develop/src/NzbDrone.Core/IndexerSearch/EpisodeSearchCommand.cs and https://github.com/Sonarr/Sonarr/blob/develop/src/Sonarr.Api.V3/Commands/CommandController.cs\n"
"widget.tsx": "import { Alert, Badge, Button, Group, Loader, Progress, SegmentedControl, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport type { Connection, Overview, SonarrOptions } from \"./types\";\nexport default function Sonarr() {\n const host = useWidgetHost(); const services = useWidgetServices(); const options = useWidgetOptions<SonarrOptions>();\n const [tab,setTab] = useWidgetState(\"tab\",\"calendar\");\n const [source,setSource] = useWidgetState(\"source\",\"all\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const query = useWidgetQuery<Overview>(\"overview\",{}, {refetchInterval:60000});\n const search = useWidgetAction<{id:number;status:string},{connection:Connection;episodeIds:number[]}>(\"searchEpisodes\");\n const label = (connection:Connection) => sources.find((source) => source.connection === connection)?.label ?? connection;\n const sources = query.data?.sources ?? [];\n const rows = sources.filter((entry) => source === \"all\" || source === entry.connection).flatMap((entry) => {\n let items = entry.calendar;\n if (tab === \"missing\") items = entry.missing.items;\n if (tab === \"queue\") items = entry.queue.items;\n return items.map((item,index) => ({...item,connection:entry.connection,rowKey:`${entry.connection}:${tab}:${item.id ?? index}`}));\n }).filter((item) => `${item.title} ${item.subTitle ?? item.seriesTitle ?? \"\"}`.toLowerCase().includes(filter.toLowerCase()));\n const visible = host.displayMode === \"advanced\" ? rows : rows.slice(0,4);\n const requestSearch = async (connection:Connection,id:number,title:string) => {\n if (await services.confirm({title:\"Search for this episode?\",message:`Ask ${label(connection)} to find and download an eligible release for ${title}?`})) search.mutate({connection,episodeIds:[id]});\n };\n return <Stack h=\"100%\" p=\"md\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge>{sources.reduce((sum,entry) => sum+entry.queue.totalCount,0)} queued</Badge></Group>\n <SegmentedControl value={tab} onChange={setTab} data={[{label:\"Upcoming\",value:\"calendar\"},{label:\"Missing\",value:\"missing\"},{label:\"Queue\",value:\"queue\"}]} />\n {sources.length > 1 && <Select aria-label=\"Sonarr server\" value={source} onChange={(value) => setSource(value ?? \"all\")} data={[{value:\"all\",label:\"All servers\"},...sources.map((entry) => ({value:entry.connection,label:label(entry.connection)}))]} />}\n {host.displayMode === \"advanced\" && <TextInput aria-label=\"Filter episodes\" placeholder=\"Filter series or episode…\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />}\n {query.isPending && <Loader size=\"sm\" />}\n {query.error && <Alert color=\"red\">{query.error.message}</Alert>}\n {sources.filter((entry) => entry.error).map((entry) => <Alert key={entry.connection} color=\"red\" title={label(entry.connection)}>{entry.error}</Alert>)}\n {search.error && <Alert color=\"red\">{search.error.message}</Alert>}\n {search.data?.id && <Text size=\"xs\" c=\"teal\">Search command {search.data.id}: {search.data.status}. Sonarr will select an eligible release.</Text>}\n {visible.map((item) => <Stack key={item.rowKey} gap={4}><Group justify=\"space-between\"><Text fw={500} size=\"sm\">{item.subTitle ?? item.seriesTitle ?? item.title}</Text><Badge size=\"xs\" variant=\"light\">{label(item.connection)}</Badge></Group><Text size=\"xs\" c=\"dimmed\">{item.title}{item.seasonNumber !== undefined && ` · S${item.seasonNumber} E${item.episodeNumber ?? \"?\"}`}</Text>{item.startDate && <Text size=\"xs\">{new Date(item.startDate).toLocaleString(host.locale)}</Text>}{item.percentComplete !== undefined && <Progress value={item.percentComplete} aria-label={`${item.title} download progress`} />}<Group gap=\"xs\">{(item.link || item.links?.[0]?.href) && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.navigate(item.link ?? item.links?.[0]?.href ?? \"\")}>Open in Sonarr</Button>}{tab === \"missing\" && typeof item.id === \"number\" && <Button size=\"compact-xs\" disabled={host.isEditMode || search.isPending} onClick={() => void requestSearch(item.connection,item.id!,item.title)}>Search episode</Button>}</Group></Stack>)}\n {query.data && !rows.length && <Text c=\"dimmed\">No episodes match this view.</Text>}\n <Group><Button size=\"xs\" variant=\"light\" onClick={() => void query.refetch()}>Refresh</Button>{host.displayMode === \"compact\" && <Button size=\"xs\" variant=\"subtle\" onClick={services.openAdvanced}>All episodes and filters</Button>}</Group>\n </Stack>;\n}\n",
"server.ts": "import type { EpisodeRow, SearchInput, SonarrOptions } from \"./types\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetConnectionGroup, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nexport default defineWidgetServer({\n overview: async (_input, context) => {\n const {options} = await getWidgetServerContext<SonarrOptions>(context);\n const start = new Date();\n const end = new Date(start.getTime()+Math.max(1,Math.min(90,Number(options.days)||7))*86400000);\n const names = await getWidgetConnectionGroup(context, \"sonarr\");\n if (!names.length) throw new Error(\"Bind at least one Sonarr integration\");\n const sources = await Promise.all(names.map(async ({connection,label}) => {\n const call = <T,>(capability:string,input:unknown) => callWidgetIntegration<T>(context,{connection,capability,input});\n try {\n const [calendar,missing,queue] = await Promise.all([\n call<EpisodeRow[]>(\"sonarr.calendar\",{start:start.toISOString(),end:end.toISOString()}),\n call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.missing\",{limit:100}), call<{items:EpisodeRow[];totalCount:number}>(\"sonarr.queue\",{limit:100}),\n ]);\n return {connection,label,calendar,missing,queue};\n } catch (error) { return {connection,label,calendar:[],missing:{items:[],totalCount:0},queue:{items:[],totalCount:0},error:error instanceof Error ? error.message : String(error)}; }\n }));\n return {sources};\n },\n searchEpisodes: async (input:SearchInput,context) => {\n const connections = await getWidgetConnectionGroup(context, \"sonarr\");\n if (!connections.some(({connection}) => connection === input?.connection) || !Array.isArray(input?.episodeIds) || !input.episodeIds.length || input.episodeIds.length > 100 || input.episodeIds.some((id) => !Number.isInteger(id) || id < 1)) throw new Error(\"Choose valid episode IDs from a declared Sonarr server\");\n return callWidgetIntegration(context,{connection:input.connection,capability:\"sonarr.searchEpisodes\",input:{episodeIds:input.episodeIds}});\n },\n});\n",
"types.ts": "export type Connection = string;\nexport interface EpisodeRow { id?:number; title:string; subTitle?:string; seriesTitle?:string; startDate?:string; seasonNumber?:number; episodeNumber?:number; percentComplete?:number; status?:string; timeLeft?:string|null; link?:string; links?:{href:string}[]; }\nexport interface Source { connection:Connection; label:string; calendar:EpisodeRow[]; missing:{items:EpisodeRow[];totalCount:number}; queue:{items:EpisodeRow[];totalCount:number}; error?:string; }\nexport interface Overview { sources:Source[]; }\nexport type SonarrOptions = {title:string;days:number};\nexport type SearchInput = {connection:Connection;episodeIds:number[]};\n",
"README.md": "# Sonarr operations\n\nChoose one or more Sonarr integrations in Connections. Each server contributes an independent calendar, missing list and queue. A failed server leaves other results visible. Search controls always name the selected server. Expand for the detailed view.\n\nThe server uses getWidgetConnectionGroup(context, \"sonarr\") to enumerate the repeatable connection requirement; adding a server requires no source edit.\n"
},
"dependencies": {},
"connections": {
"sonarr": {
"kind": "integration",
"label": "Sonarr integration"
},
"secondary": {
"kind": "integration",
"label": "Second Sonarr integration",
"optional": true
"integrationKind": "sonarr",
"multiple": true,
"label": "Sonarr servers",
"description": "Select one or more existing Homarr Sonarr integrations. Add another server at any time."
}
},
"options": {
@@ -65,16 +68,6 @@
"label": "Title",
"default": "Sonarr"
},
"primaryName": {
"control": "text",
"label": "Primary server label",
"default": "Primary"
},
"secondaryName": {
"control": "text",
"label": "Second server label",
"default": "Secondary"
},
"days": {
"control": "number",
"label": "Calendar days",
+9
View File
@@ -87,10 +87,19 @@ export function getWidgetServerContext<TOptions extends Record<string, unknown>
options: TOptions;
installationId: string;
bindingNames: string[];
bindingLabels: Record<string, string>;
isPreview: boolean;
}>;
}
/** Enumerate every member of a repeatable connection requirement, including its original named binding. */
export async function getWidgetConnectionGroup(context: WidgetServerContext, name: string) {
const { bindingNames, bindingLabels } = await getWidgetServerContext(context);
return bindingNames
.filter((binding) => binding === name || binding.startsWith(`${name}:`))
.map((connection) => ({ connection, label: bindingLabels[connection] ?? connection }));
}
export function callWidgetIntegration<T = unknown>(context: WidgetServerContext, input: WidgetIntegrationInput) {
return context.sdk.invoke("integration.call", input) as Promise<T>;
}
+1
View File
@@ -33,6 +33,7 @@
"./widget-integration-select": "./src/widget-integration-select.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts",
"./preview": "./src/widget-preview.tsx",
"./refetch-intervals": "./src/refetch-intervals.ts",
"./widget-card-shell": "./src/widget-card-shell.tsx"
},
+62 -138
View File
@@ -7,26 +7,21 @@ import type { ComponentType, FormEvent, PropsWithChildren } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Center,
CloseButton,
ColorInput,
Divider,
Group,
Input as MantineInput,
NumberInput,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
useMantineTheme,
} from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { useElementSize } from "@mantine/hooks";
import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
@@ -56,6 +51,7 @@ import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
import { WidgetPreviewFrame } from "../widget-preview";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -152,23 +148,9 @@ const WidgetEditPreview = ({
resize,
PreviewWrapper,
}: WidgetEditPreviewProps) => {
const t = useI18n();
const tItem = useI18n("item.edit");
const board = useOptionalBoard();
const generatedPreviewId = useId().replaceAll(":", "");
const { ref, width: availableWidth, height: availableHeight } = useElementSize<HTMLDivElement>();
const sourceWidth = Math.max(dimensions.width, 1);
const sourceHeight = Math.max(dimensions.height, 1);
let maximumScale = dimensions.scale ?? 0.9;
if (!Number.isFinite(maximumScale) || maximumScale <= 0) maximumScale = 0.9;
maximumScale = Math.min(maximumScale, 0.9);
let previewScale = maximumScale;
if (availableWidth > 0 && availableHeight > 0) {
previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, maximumScale);
}
const previewWidth = sourceWidth * previewScale;
const previewHeight = sourceHeight * previewScale;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false;
const isMissingIntegration = integrationRequired && state.integrationIds.length === 0;
@@ -188,129 +170,71 @@ const WidgetEditPreview = ({
}
const isPendingCustomWidget = kind === "customApi" && !itemId;
const previewOpacity = (board?.opacity ?? 100) / 100;
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
if (typeof value !== "number" || !Number.isFinite(value) || !resize) return;
resize.onChange(
normalizePreviewSize(
{
...resize.size,
[dimension]: value,
},
resize.maximumSize,
),
);
};
return (
<Stack className={classes.previewPanel} gap={0}>
<Center ref={ref} className={classes.previewCanvas}>
{resize && (
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
<Text size="xs" fw={600} c="dimmed">
{tItem("preview.size")}
</Text>
<Tooltip label={t("item.moveResize.field.width.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.width.label")}
value={resize.size.width}
onChange={(value) => handleResizeValue("width", value)}
min={1}
max={resize.maximumSize.width}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t("item.moveResize.field.width.shortLabel")}
leftSectionPointerEvents="none"
/>
</Tooltip>
<Text size="xs" c="dimmed" aria-hidden>
×
</Text>
<Tooltip label={t("item.moveResize.field.height.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.height.label")}
value={resize.size.height}
onChange={(value) => handleResizeValue("height", value)}
min={1}
max={resize.maximumSize.height}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t("item.moveResize.field.height.shortLabel")}
leftSectionPointerEvents="none"
/>
</Tooltip>
</Group>
)}
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
</Badge>
{isMissingIntegration ? (
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Box className={classes.previewViewport} w={previewWidth} h={previewHeight}>
<WidgetCardShell
className={classes.previewWidget}
kind={kind}
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
w={sourceWidth}
h={sourceHeight}
p={0}
data-grid-item-content
style={{ transform: `scale(${previewScale})` }}
>
<WidgetTitleBadge
<WidgetPreviewFrame dimensions={dimensions} resize={resize}>
{({ width: sourceWidth, height: sourceHeight, scale: previewScale }) => (
<>
{isMissingIntegration ? (
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Box className={classes.previewViewport} w={sourceWidth * previewScale} h={sourceHeight * previewScale}>
<WidgetCardShell
className={classes.previewWidget}
kind={kind}
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
/>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
<SpotlightProvider>
<IntegrationProvider integrations={previewIntegrations}>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
displayScale={previewScale}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
itemId={componentItemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</IntegrationProvider>
</SpotlightProvider>
</PreviewRuntimeBoundary>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</WidgetCardShell>
</Box>
)}
</Center>
</Stack>
w={sourceWidth}
h={sourceHeight}
p={0}
data-grid-item-content
style={{ transform: `scale(${previewScale})` }}
>
<WidgetTitleBadge
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
/>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
<SpotlightProvider>
<IntegrationProvider integrations={previewIntegrations}>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={sourceWidth}
height={sourceHeight}
displayScale={previewScale}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
itemId={componentItemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</IntegrationProvider>
</SpotlightProvider>
</PreviewRuntimeBoundary>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</WidgetCardShell>
</Box>
)}
</>
)}
</WidgetPreviewFrame>
);
};
+68
View File
@@ -0,0 +1,68 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Center, Group, NumberInput, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useI18n } from "@homarr/translation/client";
import classes from "./modals/widget-edit-modal.module.css";
type Size = { width: number; height: number };
/** The same board grid controls and fitted canvas used by Edit Widget and package authoring. */
export function WidgetPreviewFrame({ dimensions, resize, children }: {
dimensions: Size & { scale?: number };
resize?: { size: Size; maximumSize: Size; onChange(size: Size): void };
children(dimensions: Size & { scale: number }): ReactNode;
}) {
const t = useI18n();
const { ref, width, height } = useElementSize<HTMLDivElement>();
const sourceWidth = Math.max(dimensions.width, 1);
const sourceHeight = Math.max(dimensions.height, 1);
let scale = dimensions.scale ?? 0.9;
if (!Number.isFinite(scale) || scale <= 0) scale = 0.9;
scale = Math.min(scale, 0.9);
if (width > 0 && height > 0) scale = Math.min(width / sourceWidth, height / sourceHeight, scale);
const change = (dimension: keyof Size, value: string | number) => {
if (!resize || typeof value !== "number" || !Number.isFinite(value)) return;
resize.onChange({ ...resize.size, [dimension]: Math.max(1, Math.min(Math.round(value), resize.maximumSize[dimension])) });
};
return (
<Stack className={classes.previewPanel} gap={0}>
<Center ref={ref} className={classes.previewCanvas}>
{resize && (
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
<Text size="xs" fw={600} c="dimmed">{t("item.edit.preview.size")}</Text>
{(["width", "height"] as const).map((dimension) => (
<Group key={dimension} gap={6} wrap="nowrap">
{dimension === "height" && <Text size="xs" c="dimmed" aria-hidden>×</Text>}
<Tooltip label={t(`item.moveResize.field.${dimension}.label`)}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t(`item.moveResize.field.${dimension}.label`)}
value={resize.size[dimension]}
onChange={(value) => change(dimension, value)}
min={1}
max={resize.maximumSize[dimension]}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t(`item.moveResize.field.${dimension}.shortLabel`)}
leftSectionPointerEvents="none"
/>
</Tooltip>
</Group>
))}
</Group>
)}
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
</Badge>
{children({ width: sourceWidth, height: sourceHeight, scale })}
</Center>
</Stack>
);
}