feat(custom-widgets): add extensible widget platform
This commit is contained in:
1 parent
6cca757493
commit
8f152f1929
317 files changed
+32326
-1788
No files matched your search
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.backup",
|
||||
"version": "1.1.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Filesystem backup monitor",
|
||||
"description": "Read backup files and a JSON status/history report without an API, with an optional owner-configured backup executable.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"files": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"runBackup": {
|
||||
"kind": "action",
|
||||
"permission": "full",
|
||||
"timeoutMs": 300000,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"widget.tsx": "import type { BackupOptions, BackupSnapshot, BackupRunResult } from \"./types\";\nimport { Alert, Badge, Button, Group, Loader, Stack, Table, Text } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetClock, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nexport default function Backups() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const options = useWidgetOptions<BackupOptions>();\n const now = useWidgetClock(60000);\n const query = useWidgetQuery<BackupSnapshot>(\"files\", {}, {refetchInterval:60000});\n const run = useWidgetAction<BackupRunResult,Record<string,never>>(\"runBackup\");\n const files = query.data?.files ?? [];\n const latest = files[0];\n const status = query.data?.status;\n const finishedAt = status?.finishedAt ?? latest?.modifiedAt;\n const overdue = finishedAt && now !== null && now - finishedAt > options.maximumAgeHours * 3600000;\n let label = \"No backup report\";\n let color = \"gray\";\n if (finishedAt) { label = \"Recent backup activity\"; color = \"teal\"; }\n if (status?.status === \"success\") label = \"Backup succeeded\";\n if (overdue) { label = \"Backup overdue\"; color = \"orange\"; }\n if (status?.status === \"failure\") { label = \"Backup failed\"; color = \"red\"; }\n if (query.data?.running || run.isPending || status?.status === \"running\") { label = \"Backup running\"; color = \"blue\"; }\n const execute = async () => {\n if (await services.confirm({title:\"Run backup now?\",message:\"Run the owner-configured backup executable on the Homarr server?\"})) run.mutate({});\n };\n return <Stack p=\"md\" h=\"100%\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge color={color}>{label}</Badge></Group>\n {query.isPending && <Loader size=\"sm\" />}\n {query.error && <Alert color=\"red\">{query.error.message}</Alert>}\n {(query.data?.errors ?? []).map((error,index) => <Alert key={index} color=\"yellow\">{error}</Alert>)}\n {status?.message && <Text size=\"sm\">{status.message}</Text>}\n {finishedAt && <Text size=\"sm\">{new Date(finishedAt).toLocaleString(host.locale)}</Text>}\n {latest && <Text size=\"xs\" c=\"dimmed\">{latest.name} · {(latest.bytes/1048576).toFixed(1)} MiB</Text>}\n {status?.durationSeconds != null && <Text size=\"xs\">Last duration: {status.durationSeconds}s</Text>}\n <Group><Button size=\"xs\" variant=\"light\" onClick={() => void query.refetch()}>Refresh</Button>\n {host.displayMode === \"compact\" && <Button size=\"xs\" variant=\"subtle\" onClick={services.openAdvanced}>History and files</Button>}\n {query.data?.commandConfigured && <Button size=\"xs\" disabled={host.isEditMode || query.data.running} loading={run.isPending} onClick={() => void execute()}>Run backup</Button>}\n </Group>\n {run.error && <Alert color=\"red\">{run.error.message}</Alert>}\n {run.data?.completed && <Text size=\"xs\" c=\"teal\">Command completed in {run.data.durationSeconds}s. Refresh to read its status report.</Text>}\n {host.displayMode === \"advanced\" && <>\n <Text fw={600}>Run history</Text>\n {!(query.data?.history?.length) && <Text c=\"dimmed\" size=\"sm\">Add history entries to the configured JSON status report.</Text>}\n <Table><Table.Thead><Table.Tr><Table.Th>Finished</Table.Th><Table.Th>Status</Table.Th><Table.Th>Duration</Table.Th></Table.Tr></Table.Thead><Table.Tbody>{(query.data?.history ?? []).map((entry,index) => <Table.Tr key={index}><Table.Td>{entry.finishedAt ? new Date(entry.finishedAt).toLocaleString(host.locale) : \"Unknown\"}</Table.Td><Table.Td>{entry.status}</Table.Td><Table.Td>{entry.durationSeconds ?? \"—\"}s</Table.Td></Table.Tr>)}</Table.Tbody></Table>\n <Text fw={600}>Backup files</Text>\n <Table><Table.Thead><Table.Tr><Table.Th>File</Table.Th><Table.Th>Modified</Table.Th><Table.Th>MiB</Table.Th></Table.Tr></Table.Thead><Table.Tbody>{files.map((file) => <Table.Tr key={file.name}><Table.Td>{file.name}</Table.Td><Table.Td>{new Date(file.modifiedAt).toLocaleString(host.locale)}</Table.Td><Table.Td>{(file.bytes/1048576).toFixed(1)}</Table.Td></Table.Tr>)}</Table.Tbody></Table>\n </>}\n <Text size=\"xs\" c=\"dimmed\">Activity reports do not verify restore integrity. Test restores separately.</Text>\n </Stack>;\n}\n",
|
||||
"server.ts": "import type { BackupOptions, BackupFile, BackupRecord } from \"./types\";\nimport { readdir, readFile, stat } from \"node:fs/promises\";\nimport { join, isAbsolute } from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nimport { backupCommand } from \"./command\";\nconst execute = promisify(execFile);\nlet running = false;\nconst timestamp = (value:unknown):number|null => { let next=NaN; if(typeof value===\"number\") next=value; else if(typeof value===\"string\") next=Date.parse(value); return Number.isFinite(next) ? next : null; };\nconst record = (input:unknown):BackupRecord => {\n const value = input && typeof input === \"object\" ? input as Record<string,unknown> : {};\n const status = value.status;\n return {\n status: status === \"success\" || status === \"failure\" || status === \"running\" ? status : \"unknown\",\n finishedAt: timestamp(value.finishedAt),\n durationSeconds: typeof value.durationSeconds === \"number\" ? value.durationSeconds : null,\n bytes: typeof value.bytes === \"number\" ? value.bytes : null,\n message: typeof value.message === \"string\" ? value.message.slice(0,500) : \"\",\n };\n};\nexport default defineWidgetServer({\n files: async (_input, context) => {\n const { options } = await getWidgetServerContext<BackupOptions>(context);\n const errors = [];\n const files:BackupFile[] = [];\n let status:BackupRecord|null = null;\n let history:BackupRecord[] = [];\n try {\n if (typeof options.directory !== \"string\" || !isAbsolute(options.directory)) throw new Error(\"Choose an absolute backup directory inside Homarr\");\n const entries = await readdir(options.directory, {withFileTypes:true});\n const matches = entries.filter((entry) => entry.isFile() && (!options.suffix || entry.name.endsWith(options.suffix)));\n if (matches.length > 1000) errors.push(\"The directory has over 1,000 matching files; narrow the suffix to inspect the full set.\");\n for (const entry of matches.slice(0, 1000)) {\n context.signal.throwIfAborted();\n const info = await stat(join(options.directory, entry.name));\n files.push({name:entry.name, bytes:info.size, modifiedAt:info.mtimeMs});\n }\n files.sort((a,b) => b.modifiedAt - a.modifiedAt);\n } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }\n if (options.statusFile) {\n try {\n if (typeof options.statusFile !== \"string\" || !isAbsolute(options.statusFile)) throw new Error(\"Status file must use an absolute container path\");\n if ((await stat(options.statusFile)).size > 1048576) throw new Error(\"Status report exceeds 1 MiB\");\n const report:unknown = JSON.parse(await readFile(options.statusFile, \"utf8\"));\n status = record(report);\n if (report && typeof report === \"object\" && \"history\" in report && Array.isArray(report.history)) history = report.history.slice(-100).map(record).sort((a,b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0));\n } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }\n }\n return {files:files.slice(0,100),status,history,errors,running,commandConfigured:Boolean(backupCommand.executable)};\n },\n runBackup: async (_input, context) => {\n const {isPreview} = await getWidgetServerContext(context);\n if (isPreview) return {simulated:true};\n if (!backupCommand.executable || !isAbsolute(backupCommand.executable)) throw new Error(\"Configure an absolute executable in command.ts and activate the package first\");\n if (running) throw new Error(\"A backup command is already running\");\n running = true;\n const started = Date.now();\n try {\n await execute(backupCommand.executable, backupCommand.args, {shell:false,timeout:backupCommand.timeoutMs,maxBuffer:524288,signal:context.signal});\n return {completed:true,durationSeconds:Math.round((Date.now()-started)/1000)};\n } catch (error) {\n const code = error && typeof error === \"object\" && \"code\" in error ? error.code : \"unknown\";\n throw new Error(`The configured backup command failed (${code}). Check the backup service logs.`);\n } finally { running = false; }\n },\n});\n",
|
||||
"command.ts": "// Owner configuration: edit this source file deliberately, then preview and activate.\n// The dashboard cannot supply executable paths or arguments. No shell is used.\nexport const backupCommand = {\n executable: \"\", // Example: \"/usr/local/bin/run-backup\"\n args: [] as string[],\n timeoutMs: 240000,\n};\n",
|
||||
"README.md": "# Filesystem backup monitor\nMount your backup directory and optional status JSON into Homarr. No API connection is required.\n\nThe optional status file is written by your backup system. Example:\n```json\n{\"status\":\"success\",\"finishedAt\":\"2026-09-12T01:00:00Z\",\"durationSeconds\":92,\"bytes\":1048576,\"message\":\"Nightly backup completed\",\"history\":[{\"status\":\"success\",\"finishedAt\":\"2026-09-11T01:00:00Z\",\"durationSeconds\":88}]}\n```\n`status` is success, failure, running, or unknown. `finishedAt` accepts ISO timestamps or epoch milliseconds. The widget reads at most 1 MiB and 100 history records. File timestamps alone report activity, not backup success or restore integrity.\n\nTo enable Run backup, deliberately edit `command.ts` with an absolute executable and fixed argument array. The executable must exist inside Homarr. Activate the reviewed change. The browser supplies no command text; the named full-permission action uses execFile without a shell, bounded output and a timeout. It never runs from a query or in preview. stdout/stderr are not returned to dashboard viewers. The configured program should update the status report itself.\n",
|
||||
"types.ts": "export type BackupOptions = {title:string;directory:string;suffix:string;maximumAgeHours:number;statusFile:string};\nexport interface BackupRecord {status:\"success\"|\"failure\"|\"running\"|\"unknown\";finishedAt:number|null;durationSeconds:number|null;bytes:number|null;message:string;}\nexport interface BackupFile {name:string;bytes:number;modifiedAt:number;}\nexport interface BackupSnapshot {files:BackupFile[];status:BackupRecord|null;history:BackupRecord[];errors:string[];running:boolean;commandConfigured:boolean;}\nexport interface BackupRunResult {simulated?:boolean;completed?:boolean;durationSeconds?:number;}\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Backups"
|
||||
},
|
||||
"directory": {
|
||||
"control": "text",
|
||||
"label": "Directory in the Homarr container",
|
||||
"description": "Mount the backup directory into Homarr and enter its absolute container path.",
|
||||
"default": "/backups"
|
||||
},
|
||||
"suffix": {
|
||||
"control": "text",
|
||||
"label": "Filename suffix",
|
||||
"default": ".gz"
|
||||
},
|
||||
"maximumAgeHours": {
|
||||
"control": "number",
|
||||
"label": "Maximum backup age (hours)",
|
||||
"default": 24,
|
||||
"min": 1,
|
||||
"max": 8760
|
||||
},
|
||||
"statusFile": {
|
||||
"control": "text",
|
||||
"label": "Status report JSON file",
|
||||
"description": "Optional absolute path to a backup status report inside Homarr. See README.md for the format.",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.beszel",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Custom Beszel systems",
|
||||
"description": "Two attributable Beszel sources, native persisted table layouts, eight history panels and bounded live metrics with partial-failure recovery.",
|
||||
"entrypoints": {
|
||||
"tile": "systems.tsx",
|
||||
"advanced": "systems.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"systems": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"stats": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"live": {
|
||||
"kind": "subscription",
|
||||
"permission": "view",
|
||||
"timeoutMs": 300000
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"server.ts": "import type { AttributedSystem, NativeResult, SystemRow, StatsResult, StatsInput, LiveStatsEvent } from \"./beszel-types\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext, subscribeWidgetIntegration } from \"@homarr/widget-sdk/server\";\nconst declaredConnections = new Set([\"beszel\", \"secondary\"]);\nconst getConnection = (input: {connection?:string} | undefined) => {\n const connection = input?.connection ?? \"beszel\";\n if (!declaredConnections.has(connection)) throw new Error(\"Unknown Beszel connection\");\n return connection;\n};\nexport default defineWidgetServer({\n systems: async (_input, context) => {\n const {bindingNames} = await getWidgetServerContext(context);\n const names = [...declaredConnections].filter((name) => bindingNames.includes(name));\n if (!names.length) throw new Error(\"Bind a Beszel integration to this widget first\");\n const results = await Promise.allSettled(names.map((connection) => callWidgetIntegration<NativeResult<SystemRow[]>>(context, {connection, capability:\"beszel.systems\"})));\n const data: AttributedSystem[] = [];\n const sources = results.map((result, index) => {\n const connection = names[index]!;\n if (result.status === \"rejected\") return {connection,ok:false,error:result.reason instanceof Error ? result.reason.message : String(result.reason)};\n data.push(...result.value.data.map((system) => ({...system,connection,rowKey:`${connection}:${system.id}`})));\n return {connection,ok:true,timestamp:result.value.timestamp};\n });\n return {data,sources};\n },\n stats: (input: StatsInput, context) => callWidgetIntegration<NativeResult<StatsResult>>(context, {connection:getConnection(input),capability:\"beszel.stats\",input}),\n live: (input: StatsInput, context) => subscribeWidgetIntegration<LiveStatsEvent>(context, {connection:getConnection(input),capability:\"beszel.live\",input}),\n});",
|
||||
"beszel-types.ts": "// Public result fields used from the Homarr Beszel capability adapter.\nexport interface SystemRow {\n id:string; name:string; status:\"up\"|\"down\"|\"paused\"|\"pending\";\n cpu:number; memory:number; disk:number; gpu:number; netBytes:number; uptime:number;\n hostname:string; cpuModel:string; cores:number; osName:string; agentVersion:string;\n}\nexport interface AttributedSystem extends SystemRow {connection:string;rowKey:string}\nexport interface NativeResult<T> {data:T;timestamp?:string}\nexport interface SystemStats {cpu:number;mu:number;mb:number;du:number;dio?:[number,number];dr?:number;dw?:number;b?:[number,number];ns?:number;nr?:number}\nexport interface ContainerStats {n:string;c:number;m:number;b?:[number,number];ns?:number;nr?:number}\nexport interface StatsRecord<T> {id:string;created:string;stats:T}\nexport type SystemRecord = StatsRecord<SystemStats>;\nexport type ContainerRecord = StatsRecord<ContainerStats[]>;\nexport interface StatsResult {systemStats:SystemRecord[];containerStats:ContainerRecord[]}\nexport type LiveStatsEvent = {type:\"system_stats\";record:SystemRecord}|{type:\"container_stats\";record:ContainerRecord};\nexport interface StatsInput {connection?:string;systemId:string;timePeriod?:string;includeDocker?:boolean}\n\nexport interface SystemsResult {data:AttributedSystem[];sources:{connection:string;ok:boolean;error?:string;timestamp?:string}[]}\n",
|
||||
"systems.tsx": "import type { AttributedSystem, SystemStats, ContainerStats, LiveStatsEvent, SystemsResult, NativeResult, StatsResult } from \"./beszel-types\";\nimport type { DataTableColumn, DataTableSortStatus } from \"@homarr/widget-sdk/ui\";\ntype Layout = {columnOrder:string;columnWidths:string};\ntype Selection = {connection:string;id:string;name:string};\ntype ChartData = Array<{time:string} & Record<string,string|number>>;\nimport { useCallback, useMemo } from \"react\";\nimport { Alert, Badge, Button, Group, Loader, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text, TextInput } from \"@mantine/core\";\nimport { LineChart } from \"@mantine/charts\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices, useWidgetState, useWidgetSubscription, useLocalWidgetState } from \"@homarr/widget-sdk\";\nimport { HomarrDataTable, usePersistedTableLayout } from \"@homarr/widget-sdk/ui\";\n\nconst columnAccessors = [\"name\", \"connection\", \"hostname\", \"cpu\", \"memory\", \"disk\", \"gpu\", \"netBytes\", \"uptime\", \"cpuModel\", \"cores\", \"osName\", \"agentVersion\"];\nconst colors = [\"teal.6\", \"blue.6\", \"grape.6\", \"orange.6\", \"pink.6\", \"cyan.6\"];\nconst mib = 1024 ** 2;\nconst gib = 1024 ** 3;\nconst rate = (bytes: number | undefined, legacy: number | undefined) => bytes ?? (legacy ?? 0) * mib;\nconst order = <T extends {created:string}>(records: readonly T[]) => [...records].sort((left, right) => Date.parse(left.created) - Date.parse(right.created));\n\nfunction Chart({ title, data, names, unit, locale }: {title:string;data:ChartData;names:string[];unit:string;locale:string}) {\n const formatter = (value:number) => {\n if (unit === \"%\") return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value) + \"%\";\n return new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 }).format(value) + \" \" + unit;\n };\n return <Stack gap={4} style={{ minWidth: 0 }}>\n <Text size=\"sm\" fw={600}>{title}</Text>\n {data.length === 0 ? <Text c=\"dimmed\" size=\"xs\">Waiting for samples</Text> :\n <LineChart h={180} data={data} dataKey=\"time\" series={names.map((name, index) => ({ name, color: colors[index % colors.length] }))} curveType=\"linear\" valueFormatter={formatter} withDots={false} />}\n </Stack>;\n}\n\nexport default function Systems() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const systems = useWidgetQuery<SystemsResult>(\"systems\", {}, { refetchInterval: 10000 });\n const [selected, setSelected] = useWidgetState<Selection | null>(\"system\", null);\n const [period, setPeriod] = useWidgetState(\"period\", \"1h\");\n const [search, setSearch] = useWidgetState(\"search\", \"\");\n const [sort, setSort] = useWidgetState<DataTableSortStatus<AttributedSystem>>(\"sort\", { columnAccessor: \"name\", direction: \"asc\" });\n const advanced = host.displayMode === \"advanced\";\n const live = useWidgetSubscription<LiveStatsEvent>(\"live\", { connection: selected?.connection ?? \"beszel\", systemId: selected?.id ?? \"\" }, { enabled: advanced && selected !== null && period === \"live\", maximumEvents: 120 });\n const stats = useWidgetQuery<NativeResult<StatsResult>>(\"stats\", { connection: selected?.connection ?? \"beszel\", systemId: selected?.id ?? \"\", timePeriod: period === \"live\" ? \"1h\" : period, includeDocker: true }, { enabled: advanced && selected !== null && period !== \"live\", refetchInterval: 5000 });\n const records = systems.data?.data ?? [];\n const visible = records.filter((system) => `${system.name} ${system.connection} ${system.hostname}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).toSorted((a, b) => {\n const direction = sort.direction === \"asc\" ? 1 : -1;\n const left = a[sort.columnAccessor as keyof AttributedSystem];\n const right = b[sort.columnAccessor as keyof AttributedSystem];\n if (typeof left === \"number\" && typeof right === \"number\") return (left - right) * direction;\n return String(left ?? \"\").localeCompare(String(right ?? \"\")) * direction;\n });\n const inspect = useCallback((system: AttributedSystem) => {\n setSelected({connection: system.connection, id: system.id, name: system.name});\n if (!advanced) services.openAdvanced();\n }, [advanced, services, setSelected]);\n const [layout, setLayout] = useLocalWidgetState({\n key: host.isPreview ? undefined : `custom-widget:beszel-layout:${host.itemId}:${host.userId ?? \"guest\"}`,\n version: 1, defaultValue: {columnOrder: \"\", columnWidths: \"\"},\n validate: (value): value is Layout => Boolean(value && typeof value === \"object\" && \"columnOrder\" in value && \"columnWidths\" in value && typeof value.columnOrder === \"string\" && typeof value.columnWidths === \"string\"),\n });\n const persistLayout = useCallback((patch: Partial<Layout>) => setLayout((current) => ({...current, ...patch})), [setLayout]);\n const columns = useMemo<DataTLine truncated
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"beszel": {
|
||||
"label": "Beszel integration",
|
||||
"kind": "integration"
|
||||
},
|
||||
"secondary": {
|
||||
"kind": "integration",
|
||||
"label": "Secondary Beszel integration",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.checklist",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Shared maintenance checklist",
|
||||
"description": "A persistent dashboard checklist with user, placement, or installation storage and host-native commands.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx"
|
||||
},
|
||||
"handlers": {}
|
||||
},
|
||||
"files": {
|
||||
"widget.tsx": "import type { FormEvent } from \"react\";\nimport type { WidgetStorageScope } from \"@homarr/widget-sdk\";\ntype ChecklistItem = {id:string;label:string;done:boolean};\nimport { Alert, Button, Checkbox, Group, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetCommands, useWidgetHost, useWidgetOptions, useWidgetState, useWidgetStorage } from \"@homarr/widget-sdk\";\nexport default function Checklist() {\n const host = useWidgetHost();\n const options = useWidgetOptions<{title:string;scope:WidgetStorageScope}>();\n const [draft, setDraft] = useWidgetState(\"draft\", \"\");\n const stored = useWidgetStorage<ChecklistItem[]>(options.scope, \"checklist-v1\", []);\n const items = Array.isArray(stored.data) ? stored.data : [];\n const locked = host.isEditMode || stored.isPending || stored.saving;\n const save = (value:ChecklistItem[]) => { void stored.set(value).catch(() => undefined); };\n useWidgetCommands([{ id: \"clear-completed\", label: \"Clear completed items\", disabled: locked || !items.some((item) => item.done), run: () => save(items.filter((item) => !item.done)) }]);\n const add = (event:FormEvent<HTMLFormElement>) => { event.preventDefault(); if (!draft.trim() || locked) return; save([...items, { id: crypto.randomUUID(), label: draft.trim(), done: false }]); setDraft(\"\"); };\n return <Stack p=\"md\" gap=\"sm\" h=\"100%\" style={{ overflow: \"auto\" }}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Text size=\"sm\" c=\"dimmed\">{items.filter((item) => item.done).length}/{items.length}</Text></Group>\n {(stored.error || stored.saveError) && <Alert color=\"red\">{stored.error?.message ?? stored.saveError?.message}</Alert>}\n {items.map((item) => <Checkbox key={item.id} label={item.label} checked={item.done} disabled={locked} onChange={() => save(items.map((row) => row.id === item.id ? { ...row, done: !row.done } : row))} />)}\n {!items.length && !stored.isPending && <Text c=\"dimmed\" size=\"sm\">Add your first maintenance task.</Text>}\n <form onSubmit={add}><Group wrap=\"nowrap\"><TextInput aria-label=\"New task\" placeholder=\"New task\" value={draft} onChange={(event) => setDraft(event.currentTarget.value)} style={{ flex: 1 }} /><Button type=\"submit\" disabled={locked || !draft.trim()}>Add</Button></Group></form>\n {host.displayMode === \"advanced\" && <Text size=\"xs\" c=\"dimmed\">Changes are saved to Homarr. The selected storage scope controls whether this checklist belongs to you, this tile, or every placement of this installation.</Text>}\n </Stack>;\n}"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Maintenance"
|
||||
},
|
||||
"scope": {
|
||||
"control": "select",
|
||||
"label": "Storage scope",
|
||||
"default": "instance",
|
||||
"choices": [
|
||||
{
|
||||
"label": "This tile",
|
||||
"value": "instance"
|
||||
},
|
||||
{
|
||||
"label": "My checklist",
|
||||
"value": "user"
|
||||
},
|
||||
{
|
||||
"label": "All installation placements",
|
||||
"value": "installation"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.clock",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Custom world clock",
|
||||
"description": "A source-free clock with shared compact and advanced state.",
|
||||
"entrypoints": {
|
||||
"tile": "clock.tsx",
|
||||
"advanced": "clock.tsx"
|
||||
},
|
||||
"handlers": {}
|
||||
},
|
||||
"files": {
|
||||
"clock.tsx": "import { Button, Group, Stack, Text } from \"@mantine/core\";\nimport { useWidgetClock, useWidgetCommands, useWidgetHost, useWidgetOptions, useWidgetState } from \"@homarr/widget-sdk\";\n\nexport default function Clock() {\n const now = useWidgetClock();\n const host = useWidgetHost();\n const options = useWidgetOptions<{title:string}>();\n const [showSeconds, setShowSeconds] = useWidgetState(\"showSeconds\", true);\n useWidgetCommands([{ id: \"seconds\", label: \"Toggle seconds\", run: () => setShowSeconds((value) => !value) }]);\n const zones = [host.timeZone, \"UTC\", \"America/New_York\", \"Asia/Tokyo\"];\n const time = (zone:string) => {\n if (now === null) return \"—\";\n return new Intl.DateTimeFormat(host.locale, { timeZone: zone, hour: \"2-digit\", minute: \"2-digit\", ...(showSeconds ? { second: \"2-digit\" } : {}) }).format(now);\n };\n return <Stack h=\"100%\" justify=\"center\" gap=\"sm\" p=\"md\">\n <Text size=\"sm\" c=\"dimmed\">{options.title}</Text>\n <Text size={host.displayMode === \"advanced\" ? \"3rem\" : \"2rem\"} fw={700}>{time(host.timeZone)}</Text>\n {host.displayMode === \"advanced\" && zones.filter((zone, index) => zones.indexOf(zone) === index).map((zone) =>\n <Group key={zone} justify=\"space-between\"><Text>{zone}</Text><Text ff=\"monospace\">{time(zone)}</Text></Group>\n )}\n <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => setShowSeconds((value) => !value)}>Toggle seconds</Button>\n </Stack>;\n}\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Local time"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.downloads",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Unified downloads",
|
||||
"description": "Two independent torrent or Usenet clients with attributable queues, source-specific actions, partial batch results and guest-aware controls.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"queue": {
|
||||
"kind": "query",
|
||||
"permission": "view",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection"
|
||||
],
|
||||
"properties": {
|
||||
"connection": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"secondary"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"control": {
|
||||
"kind": "action",
|
||||
"permission": "modify",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection",
|
||||
"operation"
|
||||
],
|
||||
"properties": {
|
||||
"connection": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"secondary"
|
||||
]
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pause",
|
||||
"resume",
|
||||
"pauseItem",
|
||||
"resumeItem",
|
||||
"deleteItem"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"guestAccess": true
|
||||
},
|
||||
"sources": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"controlBatch": {
|
||||
"kind": "action",
|
||||
"permission": "modify",
|
||||
"guestAccess": true,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connections",
|
||||
"operation"
|
||||
],
|
||||
"properties": {
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"secondary"
|
||||
]
|
||||
}
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pause",
|
||||
"resume"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"server.ts": "import type { BatchControl, BatchResult, ControlResult, DownloadConnection, DownloadControl, DownloadQueue, DownloadSource, SourceQueue } from \"./download-types\";\nimport type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\n\nconst declared:DownloadConnection[] = [\"client\",\"secondary\"];\nconst allowed = new Set([\"pause\",\"resume\",\"pauseItem\",\"resumeItem\",\"deleteItem\"]);\nasync function sources(context:WidgetServerContext):Promise<DownloadSource[]> {\n const {bindingNames,options} = await getWidgetServerContext(context);\n return declared.filter((connection) => bindingNames.includes(connection)).map((connection) => {\n let label = options.primaryLabel;\n if (connection === \"secondary\") label = options.secondaryLabel;\n if (typeof label !== \"string\" || !label.trim()) label = connection === \"client\" ? \"Primary client\" : \"Second client\";\n return {connection,label:String(label)};\n });\n}\nasync function source(context:WidgetServerContext,connection:DownloadConnection) {\n const result = (await sources(context)).find((candidate) => candidate.connection === connection);\n if (!result) throw new Error(\"Choose a declared, bound download client\");\n return result;\n}\nasync function queue(context:WidgetServerContext,connection:DownloadConnection):Promise<SourceQueue> {\n const selected = await source(context,connection);\n const result = await callWidgetIntegration<DownloadQueue>(context,{connection,capability:\"downloads.queue\",input:{limit:100}});\n return {...selected,data:result.data};\n}\nasync function control(context:WidgetServerContext,input:DownloadControl):Promise<ControlResult> {\n if (!allowed.has(input?.operation)) throw new Error(\"Unknown download operation\");\n await source(context,input.connection);\n if (input.operation === \"pause\" || input.operation === \"resume\") {\n await callWidgetIntegration(context,{connection:input.connection,capability:`downloads.${input.operation}`,input:{}});\n } else {\n if (typeof input.itemId !== \"string\" || !input.itemId) throw new Error(\"Choose a download from this client\");\n const current = await queue(context,input.connection);\n const item = current.data.items.find((candidate) => candidate.id === input.itemId);\n if (!item) throw new Error(\"The download is no longer in this client's queue; refresh and try again\");\n // Resolve the canonical item in the selected client. Never trust a browser-supplied client index or item object.\n await callWidgetIntegration(context,{connection:input.connection,capability:`downloads.${input.operation}`,input:{item,fromDisk:false}});\n }\n return {connection:input.connection,operation:input.operation,ok:true};\n}\nexport default defineWidgetServer({\n sources: (_input:unknown,context) => sources(context),\n queue: (input:{connection:DownloadConnection},context) => queue(context,input.connection),\n control: (input:DownloadControl,context) => control(context,input),\n controlBatch: async (input:BatchControl,context):Promise<BatchResult> => {\n if (![\"pause\",\"resume\"].includes(input?.operation)) throw new Error(\"Batch actions support pause or resume only\");\n if (!Array.isArray(input.connections) || !input.connections.length || input.connections.length > 2 || new Set(input.connections).size !== input.connections.length)\n throw new Error(\"Choose one or two different download clients\");\n const available = await sources(context);\n if (input.connections.some((connection) => !available.some((candidate) => candidate.connection === connection)))\n throw new Error(\"Every batch target must be a declared, bound client\");\n const results = await Promise.all(input.connections.map(async (connection):Promise<ControlResult> => {\n try { return await control(context,{connection,operation:input.operation}); }\n catch (error) { return {connection,operation:input.operation,ok:false,error:error instanceof Error ? error.message : String(error)}; }\n }));\n const succeeded = results.filter((result) => result.ok).length;\n return {results,succeeded,failed:results.length-succeeded};\n },\n});\n",
|
||||
"download-source.tsx": "import type { DownloadControl, DownloadItem, DownloadSource, SourceQueue, ControlResult } from \"./download-types\";\nimport { Alert, Badge, Button, Group, Progress, Stack, Text } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetHost, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nconst rate = (value:number|undefined) => `${((value ?? 0)/1048576).toFixed(1)} MiB/s`;\nexport function DownloadSourceRegion({source,filter,sort}:{source:DownloadSource;filter:string;sort:string}) {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const capabilities = useWidgetCapabilities();\n const query = useWidgetQuery<SourceQueue>(\"queue\",{connection:source.connection},{refetchInterval:10000});\n const action = useWidgetAction<ControlResult,DownloadControl>(\"control\");\n const status = query.data?.data.status;\n const error = query.error ?? action.error;\n const run = (input:DownloadControl) => {void action.mutateAsync(input).catch(() => undefined);};\n const canRun = (input:DownloadControl) => !host.isEditMode && !action.isPending && capabilities.canRun(\"control\",input);\n const remove = async (item:DownloadItem) => {\n const input:DownloadControl = {connection:source.connection,operation:\"deleteItem\",itemId:item.id};\n if (!canRun(input)) return;\n if (await services.confirm({title:\"Remove download\",message:`Remove ${item.name} from ${source.label}? Downloaded files will be kept.`,destructive:true})) run(input);\n };\n const rows = query.data?.data.items ?? [];\n let items = rows.filter((item) => item.name.toLocaleLowerCase().includes(filter.toLocaleLowerCase()));\n if (sort === \"progress\") items = items.toSorted((a,b) => b.progress-a.progress);\n if (sort === \"name\") items = items.toSorted((a,b) => a.name.localeCompare(b.name));\n if (host.displayMode === \"compact\") items = items.slice(0,3);\n const pause:DownloadControl = {connection:source.connection,operation:\"pause\"};\n const resume:DownloadControl = {connection:source.connection,operation:\"resume\"};\n return <Stack gap=\"xs\" component=\"section\" aria-label={source.label} style={{border:\"1px solid var(--mantine-color-default-border)\",borderRadius:8,padding:10}}>\n <Group justify=\"space-between\"><Text size=\"sm\" fw={600}>{source.label} \u00b7 {rows.length}</Text>{status && <Badge color={status.paused ? \"yellow\" : \"blue\"}>{status.paused ? \"Paused\" : rate(status.rates.down)}</Badge>}</Group>\n {error && <Alert color=\"yellow\" title={source.label}>{error.message}<Button size=\"compact-xs\" variant=\"subtle\" onClick={() => {action.reset();void query.refetch();}}>Retry this client</Button></Alert>}\n {action.isPending && <Text size=\"xs\" c=\"dimmed\" role=\"status\">Updating this client\u2026</Text>}\n {query.isPending && <Text size=\"xs\" c=\"dimmed\">Loading this client's queue\u2026</Text>}\n {host.displayMode === \"advanced\" && <Group gap=\"xs\"><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(pause)} onClick={() => run(pause)}>Pause this client</Button><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(resume)} onClick={() => run(resume)}>Resume this client</Button></Group>}\n {items.map((item) => {\n const toggle:DownloadControl = {connection:source.connection,operation:item.state === \"paused\" ? \"resumeItem\" : \"pauseItem\",itemId:item.id};\n const deletion:DownloadControl = {connection:source.connection,operation:\"deleteItem\",itemId:item.id};\n return <Stack key={`${source.connection}:${item.id}`} gap={4}>\n <Group justify=\"space-between\" wrap=\"nowrap\"><Text size=\"sm\" truncate>{item.name}</Text><Badge size=\"xs\">{item.state}</Badge></Group>\n <Progress aria-label={`${source.label}: ${item.name} progress`} value={item.progress*100} />\n <Group justify=\"space-between\"><Text size=\"xs\" c=\"dimmed\">{Math.round(item.progress*100)}% \u00b7 {rate(item.downSpeed)}</Text>{host.displayMode === \"advanced\" && <Group gap=\"xs\"><Button size=\"compact-xs\" variant=\"subtle\" disabled={!canRun(toggle)} onClick={() => run(toggle)}>{item.state === \"paused\" ? \"Resume\" : \"Pause\"}</Button><Button size=\"compact-xs\" color=\"red\" variant=\"subtle\" disabled={!canRun(deletion)} onClick={() => void remove(item)}>Remove</Button></Group>}</Group>\n </Stack>;\n })}\n {!query.isPending && !query.error && items.length === 0 && <Text size=\"xs\" c=\"dimmed\">No downloads match this client view.</Text>}\n {action.data?.ok && <Text size=\"xs\" c=\"green\">{source.label}: {action.data.operation} completed</Text>}\n </Stack>;\n}\n",
|
||||
"download-types.ts": "export type DownloadConnection = \"client\" | \"secondary\";\nexport type DownloadOperation = \"pause\" | \"resume\" | \"pauseItem\" | \"resumeItem\" | \"deleteItem\";\nexport interface DownloadItem {\n id:string;name:string;state:string;progress:number;downSpeed?:number;\n index:number;type:\"torrent\"|\"usenet\"|\"miscellaneous\";size:number;time:number;\n}\nexport interface DownloadQueue {data:{items:DownloadItem[];status:{paused:boolean;rates:{down:number;up?:number}}}}\nexport interface DownloadSource {connection:DownloadConnection;label:string}\nexport interface SourceQueue extends DownloadSource {data:DownloadQueue[\"data\"]}\nexport interface DownloadControl {connection:DownloadConnection;operation:DownloadOperation;itemId?:string}\nexport interface BatchControl {connections:DownloadConnection[];operation:\"pause\"|\"resume\"}\nexport interface ControlResult {connection:DownloadConnection;operation:DownloadOperation;ok:boolean;error?:string}\nexport interface BatchResult {results:ControlResult[];succeeded:number;failed:number}\nexport interface DownloadOptions {primaryLabel:string;secondaryLabel:string}\n",
|
||||
"widget.tsx": "import type { BatchControl, BatchResult, DownloadConnection, DownloadSource } from \"./download-types\";\nimport { Alert, Button, Group, MultiSelect, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetCommands, useWidgetHost, useWidgetQuery, useWidgetState } from \"@homarr/widget-sdk\";\nimport { DownloadSourceRegion } from \"./download-source\";\nexport default function Downloads() {\n const host = useWidgetHost();\n const sources = useWidgetQuery<DownloadSource[]>(\"sources\",{}, {staleTime:60000});\n const capabilities = useWidgetCapabilities();\n const batch = useWidgetAction<BatchResult,BatchControl>(\"controlBatch\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const [sort,setSort] = useWidgetState(\"sort\",\"queue\");\n const [selection,setSelection] = useWidgetState<DownloadConnection[]>(\"batch-clients\",[]);\n const available = sources.data ?? [];\n const targets = selection.filter((name) => available.some((source) => source.connection === name));\n const all = available.map((source) => source.connection);\n const canRun = (input:BatchControl) => input.connections.length > 0 && !host.isEditMode && !batch.isPending && capabilities.canRun(\"controlBatch\",input);\n const run = (input:BatchControl) => {void batch.mutateAsync(input).catch(() => undefined);};\n const pauseAll:BatchControl = {connections:all,operation:\"pause\"};\n const resumeAll:BatchControl = {connections:all,operation:\"resume\"};\n const pauseSelected:BatchControl = {connections:targets,operation:\"pause\"};\n const resumeSelected:BatchControl = {connections:targets,operation:\"resume\"};\n useWidgetCommands([{id:\"pause-all\",label:\"Pause all download clients\",disabled:!canRun(pauseAll),run:() => run(pauseAll)},{id:\"resume-all\",label:\"Resume all download clients\",disabled:!canRun(resumeAll),run:() => run(resumeAll)}]);\n return <Stack p=\"sm\" gap=\"sm\" h=\"100%\" style={{overflow:\"auto\"}}>\n <Text fw={600}>Downloads \u00b7 {available.length} clients</Text>\n {sources.error && <Alert color=\"red\">{sources.error.message}<Button size=\"xs\" variant=\"subtle\" onClick={() => void sources.refetch()}>Retry setup</Button></Alert>}\n {host.displayMode === \"advanced\" && <>\n <Group><TextInput label=\"Filter downloads\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} /><Select label=\"Sort\" value={sort} onChange={(value) => setSort(value ?? \"queue\")} data={[{value:\"queue\",label:\"Queue\"},{value:\"progress\",label:\"Progress\"},{value:\"name\",label:\"Name\"}]} /></Group>\n <Group gap=\"xs\"><Button size=\"compact-xs\" disabled={!canRun(pauseAll)} onClick={() => run(pauseAll)}>Pause all clients</Button><Button size=\"compact-xs\" disabled={!canRun(resumeAll)} onClick={() => run(resumeAll)}>Resume all clients</Button></Group>\n {available.length > 1 && <Group align=\"end\"><MultiSelect label=\"Batch targets\" data={available.map((source) => ({value:source.connection,label:source.label}))} value={selection} onChange={(values) => setSelection(all.filter((connection) => values.includes(connection)))} /><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(pauseSelected)} onClick={() => run(pauseSelected)}>Pause selected</Button><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(resumeSelected)} onClick={() => run(resumeSelected)}>Resume selected</Button></Group>}\n </>}\n {batch.isPending && <Text size=\"xs\" c=\"dimmed\" role=\"status\">Updating selected clients\u2026</Text>}\n {batch.error && <Alert color=\"red\">{batch.error.message}</Alert>}\n {batch.data && <Alert color={batch.data.failed ? \"yellow\" : \"green\"} title={`${batch.data.succeeded} succeeded \u00b7 ${batch.data.failed} failed`}>\n {batch.data.results.map((result) => <Text size=\"xs\" key={result.connection}>{available.find((source) => source.connection === result.connection)?.label ?? result.connection}: {result.ok ? `${result.operation} completed` : result.error}</Text>)}\n </Alert>}\n {available.map((source) => <DownloadSourceRegion key={source.connection} source={source} filter={filter} sort={sort} />)}\n {!sources.isPending && !sources.error && available.length === 0 && <Text c=\"dimmed\">Bind a download client to get started.</Text>}\n </Stack>;\n}\n",
|
||||
"README.md": "# Two download clients\n\nBind client and optionally secondary to existing Homarr download integrations. Set the two display labels in widget options. The tile renders independent queue queries and errors for each source; a failing or slow client does not hide its sibling. The same normalized native download capabilities support torrent and Usenet backends.\n\nEach per-item action includes the connection name and itemId. The server resolves the current item from that connection, preserving the native client index and never accepting a browser-provided item object. Removal asks for confirmation and keeps downloaded files. Per-source pause/resume work independently. Advanced view and commands can pause or resume all configured clients or selected targets. Every batch reports success/failure per client; failures do not roll back completed external actions. Retry only the failed client when needed.\n\nAll control buttons and commands consult useWidgetCapabilities. Guest controls remain disabled until the owner grants the named action and its exact source-qualified input. A narrow example grant is control with {\"connection\":\"secondary\",\"operation\":\"pause\"}. Batch grants explicitly name every target, for example controlBatch with {\"connections\":[\"client\",\"secondary\"],\"operation\":\"resume\"}. Changed bindings/code/resource options invalidate old grants. Native integration action permissions remain authoritative for signed-in users.\n\nFilters, sorting and target selection use shared SDK state across tile/advanced. Composed overview copies these modules and routes them through WidgetScope with the downloads. handler prefix.\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"client": {
|
||||
"kind": "integration",
|
||||
"label": "Primary download client"
|
||||
},
|
||||
"secondary": {
|
||||
"kind": "integration",
|
||||
"label": "Second download client",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"primaryLabel": {
|
||||
"control": "text",
|
||||
"label": "Primary client label",
|
||||
"default": "Primary client"
|
||||
},
|
||||
"secondaryLabel": {
|
||||
"control": "text",
|
||||
"label": "Second client label",
|
||||
"default": "Second client"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.household",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Household controls",
|
||||
"description": "A Home Assistant control panel with a live configuration surface, owner-bound credentials and confirmed device actions.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts",
|
||||
"configuration": "configuration.tsx"
|
||||
},
|
||||
"handlers": {
|
||||
"states": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"toggle": {
|
||||
"kind": "action",
|
||||
"permission": "modify",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["entityId"],
|
||||
"properties": {
|
||||
"entityId": {
|
||||
"type": "string",
|
||||
"pattern": "^(light|switch|input_boolean|fan)\\.[A-Za-z0-9_]+$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guestAccess": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"widget.tsx": "import type { HouseholdEntity, HouseholdOptions, ToggleInput } from \"./household-types\";\nimport { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nexport default function Household() {\n const host = useWidgetHost();\n const options = useWidgetOptions<HouseholdOptions>();\n const services = useWidgetServices();\n const query = useWidgetQuery<HouseholdEntity[]>(\"states\", {}, {refetchInterval: 10000});\n const action = useWidgetAction<unknown, ToggleInput>(\"toggle\");\n const capabilities = useWidgetCapabilities();\n const error = query.error ?? action.error;\n const entities = Array.isArray(options.entities) ? options.entities : [];\n const rows = (query.data ?? []).filter((row) => entities.includes(row.entity_id));\n const toggle = async (row: HouseholdEntity) => { if (!await services.confirm({title:row.attributes.friendly_name ?? row.entity_id, message:`Toggle ${row.entity_id}?`})) return; await action.mutateAsync({entityId:row.entity_id}).catch(() => undefined); };\n return <Stack p=\"md\" h=\"100%\" style={{overflow:\"auto\"}}><Text fw={600}>{options.title}</Text>\n {error && <Alert color=\"red\">{error.message}<Button size=\"xs\" variant=\"subtle\" onClick={() => void query.refetch()}>Retry</Button></Alert>}\n <SimpleGrid cols={Math.max(1,Math.floor(host.visibleWidth / 180))}>{rows.map((row) => <Card key={row.entity_id} withBorder><Stack gap=\"xs\"><Text fw={500}>{row.attributes.friendly_name ?? row.entity_id}</Text><Group justify=\"space-between\"><Badge color={row.state === \"on\" ? \"teal\" : \"gray\"}>{row.state}</Badge><Button title={capabilities.canRun(\"toggle\", {entityId:row.entity_id}) ? undefined : \"Your dashboard permissions do not allow this control\"} size=\"compact-xs\" disabled={!capabilities.canRun(\"toggle\", {entityId:row.entity_id}) || action.isPending || ![\"on\",\"off\"].includes(row.state)} onClick={() => void toggle(row)}>Toggle</Button></Group>{host.displayMode === \"advanced\" && <Text size=\"xs\" c=\"dimmed\">Changed {new Date(row.last_changed).toLocaleString(host.locale)}</Text>}</Stack></Card>)}</SimpleGrid>\n {!entities.length && <Text c=\"dimmed\">Choose your lights and switches in the configuration preview.</Text>}\n </Stack>;\n}",
|
||||
"server.ts": "import type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\nimport type { HouseholdEntity, HouseholdOptions, ToggleInput } from \"./household-types\";\nimport { defineWidgetServer, fetchWidgetConnection, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nconst states = async (context: WidgetServerContext) => { const result = await fetchWidgetConnection<HouseholdEntity[]>(context, {connection:\"homeassistant\",path:\"/api/states\"}); if (!result.ok) throw new Error(`Home Assistant returned HTTP ${result.status}`); return result.data; };\nexport default defineWidgetServer({\n states: (_input: unknown, context) => states(context),\n toggle: async (input: ToggleInput, context) => {\n const {options} = await getWidgetServerContext<HouseholdOptions>(context);\n if (!Array.isArray(options.entities) || !options.entities.includes(input?.entityId)) throw new Error(\"Choose this entity in the widget configuration first\");\n const domain = input.entityId.split(\".\")[0] ?? \"\";\n if (![\"light\",\"switch\",\"input_boolean\",\"fan\"].includes(domain)) throw new Error(\"This entity does not support the toggle operation\");\n const result = await fetchWidgetConnection(context, {connection:\"homeassistant\",path:`/api/services/${domain}/toggle`,method:\"POST\",body:{entity_id:input.entityId}});\n if (!result.ok) throw new Error(`Home Assistant returned HTTP ${result.status}`);\n return result.data;\n },\n});",
|
||||
"configuration.tsx": "import type { HouseholdEntity, HouseholdOptions } from \"./household-types\";\nimport { Alert, Button, MultiSelect, Stack, TextInput } from \"@mantine/core\";\nimport { useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nexport default function Configuration() {\n const options = useWidgetOptions<HouseholdOptions>();\n const services = useWidgetServices();\n const [title, setTitle] = useWidgetState(\"configuration-title\", options.title);\n const [entities, setEntities] = useWidgetState(\"configuration-entities\", options.entities);\n const [error, setError] = useWidgetState(\"configuration-error\", \"\");\n const query = useWidgetQuery<HouseholdEntity[]>(\"states\");\n const choices = (query.data ?? []).filter((row) => /^(light|switch|input_boolean|fan)\\./.test(row.entity_id)).map((row) => ({value:row.entity_id,label:row.attributes.friendly_name ?? row.entity_id}));\n const save = async () => { try { await services.updateOptions({...options,title,entities}); setError(\"\"); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } };\n return <Stack p=\"md\">{query.error && <Alert color=\"red\">{query.error.message}</Alert>}{error && <Alert color=\"red\">{error}</Alert>}<TextInput label=\"Panel title\" value={title} onChange={(event) => setTitle(event.currentTarget.value)} /><MultiSelect label=\"Lights and switches\" searchable data={choices} value={entities} onChange={setEntities} /><Button onClick={() => void save()}>Use these options</Button></Stack>;\n}",
|
||||
"household-types.ts": "export interface HouseholdOptions extends Record<string, unknown> { title: string; entities: string[] }\nexport interface HouseholdEntity { entity_id: string; state: string; last_changed: string; attributes: { friendly_name?: string } }\nexport interface ToggleInput { entityId: string }\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"homeassistant": {
|
||||
"kind": "http",
|
||||
"label": "Home Assistant",
|
||||
"description": "Use the Home Assistant base URL and bearer token authentication."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Home"
|
||||
},
|
||||
"entities": {
|
||||
"control": "json",
|
||||
"label": "Entity IDs",
|
||||
"default": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.media",
|
||||
"version": "1.1.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Media and camera wall",
|
||||
"description": "Native playing sessions plus optional authenticated camera snapshots and an Immich gallery, with responsive tiles and shared advanced image selection.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"sessions": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"gallery": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"image": {
|
||||
"kind": "query",
|
||||
"permission": "view",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["kind", "id"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["camera", "immich"]
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"widget.tsx": "import type { MediaOptions, GalleryItem } from \"./types\";\nimport { Alert, Badge, Button, Card, Group, Image, Loader, SimpleGrid, Stack, Text, UnstyledButton } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport Sessions from \"./sessions\";\nexport default function MediaWall() {\n const host = useWidgetHost(); const options = useWidgetOptions<MediaOptions>(); const services = useWidgetServices();\n const [selected,setSelected] = useWidgetState<GalleryItem|null>(\"selectedImage\",null);\n const gallery = useWidgetQuery<{items:GalleryItem[];errors:string[];configured:boolean}>(\"gallery\",{}, {refetchInterval:60000});\n const columns = Math.max(1,Math.floor(host.visibleWidth/220));\n return <Stack p=\"sm\" h=\"100%\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Button size=\"compact-xs\" variant=\"subtle\" onClick={() => void gallery.refetch()}>Refresh gallery</Button></Group>\n {gallery.error && <Alert color=\"red\">{gallery.error.message}</Alert>}\n {(gallery.data?.errors ?? []).map((error,index) => <Alert key={index} color=\"yellow\">{error}</Alert>)}\n {host.displayMode === \"advanced\" && selected && <Card withBorder><Stack gap=\"xs\"><Group justify=\"space-between\"><Text fw={600}>{selected.name}</Text><Button size=\"compact-xs\" variant=\"subtle\" onClick={() => setSelected(null)}>Close image</Button></Group><Snapshot item={selected} large /></Stack></Card>}\n <SimpleGrid cols={columns}>{(gallery.data?.items ?? []).map((item) => <Card key={`${item.kind}:${item.id}`} withBorder p=\"xs\"><UnstyledButton onClick={() => {setSelected(item);services.openAdvanced();}}><Stack gap={4}><Snapshot item={item} /><Group justify=\"space-between\"><Text size=\"sm\">{item.name}</Text><Badge size=\"xs\" variant=\"light\">{item.kind}</Badge></Group>{item.takenAt && <Text size=\"xs\" c=\"dimmed\">{new Date(item.takenAt).toLocaleDateString(host.locale)}</Text>}</Stack></UnstyledButton></Card>)}</SimpleGrid>\n {gallery.data && !gallery.data.configured && <Text size=\"sm\" c=\"dimmed\">Bind a camera or Immich connection to add authenticated images.</Text>}\n {gallery.data?.configured && !gallery.data.items.length && <Text size=\"sm\" c=\"dimmed\">No configured snapshots or matching Immich images.</Text>}\n {options.showSessions && <Sessions />}\n </Stack>;\n}\nfunction Snapshot({item,large=false}:{item:GalleryItem;large?:boolean}) {\n const options = useWidgetOptions<MediaOptions>();\n const image = useWidgetQuery<{dataUrl:string;updatedAt:string}>(\"image\",{kind:item.kind,id:item.id},{refetchInterval:item.kind === \"camera\" ? Math.max(5,Number(options.snapshotInterval)||15)*1000 : 300000,staleTime:5000});\n if (image.isPending) return <Stack h={160} justify=\"center\" align=\"center\"><Loader size=\"sm\" /></Stack>;\n if (image.error) return <Alert color=\"yellow\">{image.error.message}</Alert>;\n return <Image src={image.data?.dataUrl} alt={item.name} h={large ? 420 : 160} fit={large ? \"contain\" : \"cover\"} radius=\"sm\" />;\n}\n",
|
||||
"server.ts": "import type { Camera, MediaOptions, GalleryItem, GalleryImageInput, ImmichAsset, MediaSessions } from \"./types\";\nimport { callWidgetIntegration, defineWidgetServer, fetchWidgetConnection, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nconst cameras = (options:MediaOptions):Camera[] => Array.isArray(options.cameras) ? options.cameras.slice(0,8).filter((item) => item && typeof item.name === \"string\" && typeof item.path === \"string\") : [];\nexport default defineWidgetServer({\n sessions: async (_input,context) => {\n const {bindingNames} = await getWidgetServerContext<MediaOptions>(context);\n if (!bindingNames.includes(\"media\")) return {data:[],configured:false};\n return callWidgetIntegration<MediaSessions>(context,{connection:\"media\",capability:\"media.sessions\",input:{showOnlyPlaying:false}});\n },\n gallery: async (_input,context) => {\n const {bindingNames,options} = await getWidgetServerContext<MediaOptions>(context);\n const items:GalleryItem[] = []; const errors:string[] = [];\n if (bindingNames.includes(\"camera\")) items.push(...cameras(options).map<GalleryItem>((camera,index) => ({kind:\"camera\",id:String(index),name:camera.name})));\n if (bindingNames.includes(\"immich\")) {\n try {\n const response = await fetchWidgetConnection<{assets?:{items?:ImmichAsset[]}}>(context,{connection:\"immich\",path:\"/api/search/metadata\",method:\"POST\",body:{type:\"IMAGE\",size:Math.max(1,Math.min(12,Number(options.imageCount)||8))}});\n if (!response.ok) throw new Error(`Immich returned HTTP ${response.status}`);\n const assets = response.data?.assets?.items;\n if (!Array.isArray(assets)) throw new Error(\"Immich search returned an unexpected result\");\n items.push(...assets.slice(0,12).map<GalleryItem>((asset) => ({kind:\"immich\",id:asset.id,name:asset.originalFileName ?? \"Photo\",takenAt:asset.localDateTime ?? asset.fileCreatedAt})));\n } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }\n }\n return {items,errors,configured:bindingNames.includes(\"camera\") || bindingNames.includes(\"immich\")};\n },\n image: async (input:GalleryImageInput,context) => {\n const {options,bindingNames} = await getWidgetServerContext<MediaOptions>(context);\n let path; let connection;\n if (input?.kind === \"camera\") {\n const camera = cameras(options).find((_camera,index) => String(index) === input.id);\n if (!camera) throw new Error(\"Choose a configured camera snapshot\");\n path = camera.path; connection = \"camera\";\n } else if (input?.kind === \"immich\" && typeof input.id === \"string\" && /^[0-9a-f-]{36}$/i.test(input.id)) {\n path = `/api/assets/${encodeURIComponent(input.id)}/thumbnail?size=thumbnail`; connection = \"immich\";\n } else throw new Error(\"Unknown gallery image\");\n if (!bindingNames.includes(connection)) throw new Error(\"The image connection is not bound\");\n const response = await fetchWidgetConnection(context,{connection,path,responseType:\"base64\"});\n if (!response.ok) throw new Error(`Image returned HTTP ${response.status}. Check the configured endpoint and image permissions.`);\n const mime = response.contentType?.split(\";\")[0]?.trim().toLowerCase() ?? \"\";\n if (![\"image/jpeg\",\"image/png\",\"image/webp\",\"image/gif\",\"image/avif\"].includes(mime)) throw new Error(\"The endpoint must return a JPEG, PNG, WebP, GIF or AVIF snapshot\");\n if (typeof response.data !== \"string\" || response.data.length > 900000) throw new Error(\"Use a smaller snapshot or thumbnail (under 675 KiB)\");\n return {dataUrl:`data:${mime};base64,${response.data}`,updatedAt:response.updatedAt};\n },\n});\n",
|
||||
"sessions.tsx": "import type { MediaSessions } from \"./types\";\nimport { Alert, Badge, Button, Card, Group, Progress, SimpleGrid, Stack, Text } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nexport default function Sessions() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const query = useWidgetQuery<MediaSessions>(\"sessions\", {}, { refetchInterval: 10000 });\n const rows = query.data?.data ?? [];\n const columns = Math.max(1, Math.floor(host.visibleWidth / 240));\n return <Stack p=\"md\" style={{overflow:\"auto\"}}><Text fw={600}>Now playing · {rows.length}</Text>\n {query.error && <Alert color=\"red\">{query.error.message}<Button variant=\"subtle\" size=\"xs\" onClick={() => void query.refetch()}>Retry</Button></Alert>}\n <SimpleGrid cols={columns}>{rows.map((session) => { const playing = session.currentlyPlaying; const playback = playing?.playback; return <Card key={session.sessionId} withBorder padding=\"sm\"><Stack gap=\"xs\"><Group justify=\"space-between\"><Text size=\"sm\" fw={600}>{session.user.username}</Text><Badge size=\"xs\">{playback?.state ?? \"idle\"}</Badge></Group><Text>{playing?.name ?? session.sessionName}</Text>{playing?.episodeName && <Text size=\"xs\" c=\"dimmed\">{playing.episodeName}</Text>}{typeof playback?.durationMs === \"number\" && playback.durationMs > 0 && <Progress aria-label=\"Playback progress\" value={100 * (playback.positionMs ?? 0) / playback.durationMs} />}<Button size=\"compact-xs\" variant=\"light\" onClick={() => services.openDetail({title: session.sessionName, content: <Stack><Text>{session.user.username}</Text><Text>{playing?.name}</Text><Text size=\"sm\">{playing?.location ?? \"Unknown network\"} · {playing?.metadata?.bitrateKbps ?? \"Unknown\"} kbps</Text><Text size=\"sm\">Video direct: {String(playing?.metadata?.transcoding?.isVideoDirect ?? \"unknown\")}</Text><Text size=\"sm\">Audio direct: {String(playing?.metadata?.transcoding?.isAudioDirect ?? \"unknown\")}</Text></Stack>})}>Session details</Button></Stack></Card>; })}</SimpleGrid>\n {!query.isPending && !query.error && query.data?.configured !== false && rows.length === 0 && <Text c=\"dimmed\">No active media sessions.</Text>}\n {query.data?.configured === false && <Text c=\"dimmed\" size=\"sm\">Bind a media server integration to show now-playing sessions.</Text>}\n </Stack>;\n}",
|
||||
"README.md": "# Media and camera wall\nAll connections are optional. Bind an existing Plex/Jellyfin/Emby-compatible integration for native now-playing sessions. Bind an HTTP camera snapshot server and configure up to eight name/path entries for authenticated JPEG/PNG/WebP/GIF/AVIF still images. This is snapshot polling, not RTSP or continuous video streaming.\n\nFor Immich, bind the server root URL and keep `x-api-key` in the local connection's secret headers. The key needs asset.read and asset.view. The package lists recent image assets through POST /api/search/metadata and retrieves each thumbnail through GET /api/assets/:id/thumbnail?size=thumbnail. Each image is fetched server-side; credentials are never sent to the browser. Each image is limited to 675 KiB before base64 encoding. If an endpoint redirects, configure a direct image endpoint supported by your service.\n\nTile and advanced views share the selected image. Clicking a thumbnail opens the advanced view; the media session panel retains playback progress and native session details. Images refresh independently, so one failed camera does not hide the others. The package does not promise lossless full-resolution originals or real-time video.\n\nOfficial API sources: https://github.com/immich-app/immich/blob/main/server/src/controllers/search.controller.ts and https://github.com/immich-app/immich/blob/main/server/src/controllers/asset-media.controller.ts\n",
|
||||
"types.ts": "export type Camera = {name:string;path:string};\nexport type MediaOptions = {title:string;showSessions:boolean;cameras:Camera[];imageCount:number;snapshotInterval:number};\nexport interface GalleryItem {kind:\"camera\"|\"immich\";id:string;name:string;takenAt?:string;}\nexport type GalleryImageInput = Pick<GalleryItem,\"kind\"|\"id\">;\nexport interface ImmichAsset {id:string;originalFileName?:string;localDateTime?:string;fileCreatedAt?:string;}\nexport interface MediaSession {sessionId:string;sessionName:string;user:{username:string};currentlyPlaying:{name:string;episodeName?:string|null;location:\"lan\"|\"wan\"|null;playback:{state:\"playing\"|\"paused\"|\"buffering\"|null;positionMs:number|null;durationMs:number|null};metadata:{bitrateKbps:number|null;transcoding:{isVideoDirect:boolean;isAudioDirect:boolean}}|null}|null;}\nexport interface MediaSessions {data:MediaSession[];configured?:boolean;}\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"media": {
|
||||
"kind": "integration",
|
||||
"label": "Media server",
|
||||
"optional": true
|
||||
},
|
||||
"camera": {
|
||||
"kind": "http",
|
||||
"label": "Camera snapshot server",
|
||||
"optional": true,
|
||||
"description": "Basic, bearer or header authentication stays in this local connection."
|
||||
},
|
||||
"immich": {
|
||||
"kind": "http",
|
||||
"label": "Immich server",
|
||||
"optional": true,
|
||||
"description": "Use a secret x-api-key header with asset.read and asset.view permissions."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Media wall"
|
||||
},
|
||||
"showSessions": {
|
||||
"control": "switch",
|
||||
"label": "Show media sessions",
|
||||
"default": true
|
||||
},
|
||||
"cameras": {
|
||||
"control": "json",
|
||||
"label": "Camera snapshots",
|
||||
"description": "Array of name/path objects on the camera connection. Up to eight snapshots. Paths stay on that connection’s origin.",
|
||||
"default": [
|
||||
{
|
||||
"name": "Front door",
|
||||
"path": "/snapshot.jpg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"imageCount": {
|
||||
"control": "number",
|
||||
"label": "Immich image count",
|
||||
"default": 8,
|
||||
"min": 1,
|
||||
"max": 12
|
||||
},
|
||||
"snapshotInterval": {
|
||||
"control": "number",
|
||||
"label": "Snapshot refresh seconds",
|
||||
"default": 15,
|
||||
"min": 5,
|
||||
"max": 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.mqtt",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "MQTT live topics",
|
||||
"description": "Live broker topics, retained values, recent message history and explicit owner-configured publishes.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"setup": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"messages": {
|
||||
"kind": "subscription",
|
||||
"permission": "view"
|
||||
},
|
||||
"publish": {
|
||||
"kind": "action",
|
||||
"permission": "modify"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"server.ts": "import type { BrokerMessage, BrokerEvent, PublishInput } from \"./mqtt-types\";\nimport type { WidgetJson } from \"@homarr/widget-sdk/server\";\nimport { defineWidgetServer } from \"@homarr/widget-sdk/server\";\nimport { brokerSettings, connectBroker } from \"./mqtt-adapter\";\n\nexport default defineWidgetServer({\n setup: async (_input,context) => {\n const {topics,publishTopics} = await brokerSettings(context);\n return {topics,publishTopics};\n },\n messages: async function* (_input,context):AsyncGenerator<BrokerEvent> {\n const configuration = await brokerSettings(context);\n const {client,close} = await connectBroker(context,configuration);\n const queue:BrokerMessage[] = [];\n let wake = () => {};\n let failure:Error|undefined;\n let dropped = 0;\n const onMessage = (topic:string,buffer:Buffer,packet:{retain:boolean}) => {\n const truncated = buffer.length > 65536;\n const text = buffer.subarray(0,65536).toString(\"utf8\");\n let payload:WidgetJson = text;\n try { payload = JSON.parse(text); } catch {}\n if (queue.length >= 64) {queue.shift();dropped++;}\n queue.push({kind:\"message\",topic,payload,truncated,retained:packet.retain,dropped,receivedAt:new Date().toISOString()});\n wake();\n };\n const onError = (error:Error) => {failure=error;wake();};\n const onClose = () => {if (!context.signal.aborted) onError(new Error(\"Broker connection closed; reconnect to resume\"));};\n const onAbort = () => wake();\n client.on(\"message\",onMessage);client.on(\"error\",onError);client.on(\"close\",onClose);\n context.signal.addEventListener(\"abort\",onAbort,{once:true});\n try {\n const grants = await client.subscribeAsync(configuration.topics,{qos:0});\n if (grants.some((grant) => grant.qos === 128)) throw new Error(\"The broker denied a configured topic subscription\");\n yield {kind:\"connected\",topics:configuration.topics,receivedAt:new Date().toISOString()};\n while (!context.signal.aborted) {\n if (failure) throw failure;\n if (queue.length) {yield queue.shift()!;continue;}\n await new Promise<void>((resolve) => {wake=resolve;if (context.signal.aborted || failure || queue.length) resolve();});\n }\n } finally {\n context.signal.removeEventListener(\"abort\",onAbort);\n client.off(\"message\",onMessage);client.off(\"error\",onError);client.off(\"close\",onClose);\n await close();\n }\n },\n publish: async (input:PublishInput,context) => {\n const configuration = await brokerSettings(context);\n if (!configuration.publishTopics.includes(input?.topic)) throw new Error(\"Choose an owner-configured publish topic\");\n if (typeof input.payload !== \"string\" || Buffer.byteLength(input.payload,\"utf8\") > 4096) throw new Error(\"The payload must be a string under 4 KiB\");\n const {client,close} = await connectBroker(context,configuration);\n try {\n await client.publishAsync(input.topic,input.payload,{qos:1,retain:false});\n return {published:true,topic:input.topic};\n } finally { await close(); }\n },\n});\n",
|
||||
"mqtt-types.ts": "import type { WidgetJson } from \"@homarr/widget-sdk/server\";\nexport interface BrokerMessage {kind:\"message\";topic:string;payload:WidgetJson;truncated:boolean;retained:boolean;dropped:number;receivedAt:string}\nexport interface BrokerConnected {kind:\"connected\";topics:string[];receivedAt:string}\nexport type BrokerEvent = BrokerMessage | BrokerConnected;\nexport interface PublishInput {topic:string;payload:string}\n",
|
||||
"mqtt.d.ts": "// Portable declarations for the exact MQTT.js 5.15.2 adapter API used here.\n// Draft checking does not fetch npm packages. Runtime code still comes from the pinned dependency.\ndeclare module \"mqtt/lib/client\" {\n import { EventEmitter } from \"node:events\";\n import type { Duplex } from \"node:stream\";\n interface Options {clientId:string;username?:string;password?:string;reconnectPeriod:number;connectTimeout:number;clean:boolean;manualConnect:boolean;log:(...values:unknown[])=>void}\n export default class MqttClient extends EventEmitter {\n constructor(streamBuilder:()=>Duplex,options:Options);\n connect():this;\n end(force?:boolean):this;\n endAsync(force?:boolean):Promise<void>;\n subscribeAsync(topics:string[],options:{qos:0|1|2}):Promise<{qos:0|1|2|128}[]>;\n publishAsync(topic:string,payload:string,options:{qos:0|1|2;retain:boolean}):Promise<void>;\n }\n}\n",
|
||||
"README.md": "# MQTT live topics\n\nCreate a Service connection with serviceType `mqtt`. Nonsecret settings:\n\n```json\n{\"url\":\"mqtt://mosquitto:1883\",\"topics\":[\"homelab/status/#\",\"sensors/+/temperature\"],\"publishTopics\":[\"homelab/commands/refresh\"]}\n```\n\nEncrypted credentials: {\"username\":\"homarr\",\"password\":\"YOUR_BROKER_PASSWORD\"}. The browser never receives credentials through SDK connection APIs. TLS brokers use mqtts://. This adapter uses MQTT.js\u2019s client constructor with Node TCP/TLS streams. Optional nonsecret settings ca/cert/rejectUnauthorized configure TLS; keep tlsKey in encrypted credentials. Use a distinct connection when broker or credential sets differ.\n\nThe server adapter opens an authenticated subscription only while observed, disables hidden reconnect loops and closes on cancellation, errors and worker shutdown. The host shares matching subscriptions across surfaces. The browser retains 64 recent events; the worker queue is bounded to 64 messages and each payload to 64 KiB. The tile shows latest topic values; advanced view shows recent messages and a nonretained publish action. Only explicit publishTopics accept publishes, with 4 KiB payloads and QoS 1. Clear publishTopics for a read-only dashboard. Broker permissions still apply. Public guest actions are off by default.\n\nDiagnose verifies TCP reachability for mqtt:// and mqtts://. Run preview to validate TLS, broker credentials and topic authorization; use reconnect after fixing the service. Enabling live SDK actions is required to actually publish from preview. No HTTP URL or proxy is involved. This package pins mqtt@5.15.2 and supports-color@8.1.1 (debug\u2019s optional JavaScript dependency); review and trust installs its dependency tree with lifecycle scripts disabled. Source: https://github.com/mqttjs/MQTT.js\n",
|
||||
"mqtt-adapter.ts": "import type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\ninterface LocalMqttSettings {url?:string;topics?:unknown[];publishTopics?:unknown[];ca?:string;cert?:string;rejectUnauthorized?:boolean}\nimport MqttClient from \"mqtt/lib/client\";\nimport { createConnection, isIP } from \"node:net\";\nimport { connect as connectTls } from \"node:tls\";\nimport { randomUUID } from \"node:crypto\";\nimport { getWidgetConnection } from \"@homarr/widget-sdk/server\";\n\nexport async function brokerSettings(context:WidgetServerContext) {\n const {configuration,secrets} = await getWidgetConnection<LocalMqttSettings>(context,{connection:\"broker\",serviceType:\"mqtt\"});\n const settings = configuration.settings ?? {};\n if (typeof settings.url !== \"string\") throw new Error(\"Set the MQTT service url, for example mqtt://mosquitto:1883\");\n const url = new URL(settings.url);\n if (![\"mqtt:\",\"mqtts:\"].includes(url.protocol)) throw new Error(\"Choose an mqtt:// or mqtts:// broker URL\");\n if (url.username || url.password) throw new Error(\"Put the broker username and password in encrypted credentials, not the URL\");\n const topics = Array.isArray(settings.topics) ? settings.topics.filter((topic):topic is string => typeof topic === \"string\" && topic.length > 0).slice(0,32) : [];\n if (!topics.length) throw new Error(\"Set a nonempty topics array on the broker connection\");\n const publishTopics = Array.isArray(settings.publishTopics) ? settings.publishTopics.filter((topic):topic is string => typeof topic === \"string\" && topic.length > 0 && !/[+#]/.test(topic)).slice(0,32) : [];\n return {settings:{...settings,url:settings.url},secrets,topics,publishTopics,timeoutMs:Math.min(configuration.timeoutMs,15000)};\n}\n\nexport async function connectBroker(context:WidgetServerContext,configuration:Awaited<ReturnType<typeof brokerSettings>>) {\n context.signal.throwIfAborted();\n const {settings,secrets,timeoutMs} = configuration;\n const url = new URL(settings.url);\n const hostname = url.hostname.replace(/^\\[|\\]$/g, \"\");\n let port = url.protocol === \"mqtts:\" ? 8883 : 1883;\n if (url.port) port = Number(url.port);\n const stream = () => {\n if (url.protocol !== \"mqtts:\") return createConnection({host:hostname,port});\n return connectTls({host:hostname,port,servername:isIP(hostname) ? undefined : hostname,ca:settings.ca,cert:settings.cert,key:secrets.tlsKey,rejectUnauthorized:settings.rejectUnauthorized !== false});\n };\n const client = new MqttClient(stream,{\n clientId:`homarr-${randomUUID()}`,\n username:secrets.username || undefined,\n password:secrets.password || undefined,\n reconnectPeriod:0,\n connectTimeout:timeoutMs,\n clean:true,\n manualConnect:true,\n log:() => {},\n });\n const ignoreError = () => {};\n client.on(\"error\",ignoreError);\n const stop = () => { client.end(true); };\n context.signal.addEventListener(\"abort\",stop,{once:true});\n const close = async () => {\n context.signal.removeEventListener(\"abort\",stop);\n await client.endAsync(true).catch(() => undefined);\n client.removeListener(\"error\",ignoreError);\n };\n try {\n await new Promise<void>((resolve,reject) => {\n const cleanup = () => {client.off(\"connect\",ready);client.off(\"error\",failed);client.off(\"close\",closed);};\n const ready = () => {cleanup();resolve();};\n const failed = (error:unknown) => {cleanup();reject(error);};\n const closed = () => failed(context.signal.reason ?? new Error(\"Broker connection closed before authentication completed\"));\n client.once(\"connect\",ready);client.once(\"error\",failed);client.once(\"close\",closed);\n if (context.signal.aborted) closed();\n else client.connect();\n });\n context.signal.throwIfAborted();\n return {client,close};\n } catch (error) { await close(); throw error; }\n}\n",
|
||||
"widget.tsx": "import type server from \"./server\";\nimport type { BrokerEvent, BrokerMessage, PublishInput } from \"./mqtt-types\";\nimport { Alert, Badge, Button, Group, Select, Stack, Text, Textarea, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetHost, useWidgetServices, useWidgetOptions, useWidgetQuery, useWidgetState, useWidgetSubscription } from \"@homarr/widget-sdk\";\n\nexport default function MqttWidget() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const options = useWidgetOptions<{title:string}>();\n const setup = useWidgetQuery<Awaited<ReturnType<typeof server.setup>>>(\"setup\",{}, {staleTime:60000});\n const stream = useWidgetSubscription<BrokerEvent>(\"messages\",{}, {maximumEvents:64});\n const publish = useWidgetAction<Awaited<ReturnType<typeof server.publish>>,PublishInput>(\"publish\");\n const capabilities = useWidgetCapabilities();\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const [topic,setTopic] = useWidgetState(\"publishTopic\",\"\");\n const [payload,setPayload] = useWidgetState(\"payload\",\"\");\n const events = stream.events.filter((event):event is BrokerMessage => event.kind === \"message\" && event.topic.includes(filter)).slice().reverse();\n const latest = [...new Map(events.map((event) => [event.topic,event] as const).reverse()).values()].reverse();\n let visible = latest.slice(0,8);\n if (host.displayMode === \"advanced\") visible = events;\n const input = {topic,payload};\n const error = setup.error || stream.error || publish.error;\n return <Stack h=\"100%\" p=\"sm\" gap=\"xs\">\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge color={stream.connected ? \"green\" : \"gray\"}>{stream.connected ? \"Live\" : \"Disconnected\"}</Badge></Group>\n {error && <Alert color=\"red\">{error.message}<Button size=\"compact-xs\" variant=\"subtle\" onClick={() => stream.reconnect()}>Reconnect</Button></Alert>}\n <TextInput size=\"xs\" aria-label=\"Filter MQTT topics\" placeholder=\"Filter topics\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} />\n <Stack gap=\"xs\" style={{flex:1,minHeight:0,overflow:\"auto\"}}>\n {!visible.length && <Text size=\"sm\" c=\"dimmed\">Waiting for messages on {setup.data?.topics.join(\", \") || \"configured topics\"}. Retained broker messages appear immediately.</Text>}\n {visible.map((event,index) => <Stack key={`${event.topic}:${event.receivedAt}:${index}`} gap={2} style={{borderBottom:\"1px solid var(--mantine-color-default-border)\",paddingBottom:6}}>\n <Group justify=\"space-between\"><Text size=\"xs\" fw={600}>{event.topic}</Text>{event.retained && <Badge size=\"xs\" variant=\"light\">Retained</Badge>}</Group>\n <Text component=\"pre\" size=\"xs\" style={{whiteSpace:\"pre-wrap\",margin:0}}>{typeof event.payload === \"string\" ? event.payload : JSON.stringify(event.payload,null,2)}</Text>\n <Text size=\"xs\" c=\"dimmed\">{new Date(event.receivedAt).toLocaleTimeString(host.locale)}{event.truncated ? \" \u00b7 payload truncated\" : \"\"}{event.dropped ? ` \u00b7 ${event.dropped} buffered messages dropped` : \"\"}</Text>\n </Stack>)}\n </Stack>\n {host.displayMode !== \"advanced\" && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.openAdvanced()}>Open message history</Button>}\n {host.displayMode === \"advanced\" && (setup.data?.publishTopics.length ?? 0) > 0 && <Stack gap=\"xs\">\n <Select label=\"Publish topic\" data={setup.data?.publishTopics ?? []} value={topic || null} onChange={(value) => setTopic(value || \"\")} />\n <Textarea label=\"Message payload\" value={payload} onChange={(event) => setPayload(event.currentTarget.value)} maxLength={4096} />\n <Button disabled={!topic || !capabilities.canRun(\"publish\",input)} loading={publish.isPending} onClick={async () => {if (await services.confirm({title:\"Publish MQTT message\",message:`Send this payload to ${topic}?`})) await publish.mutateAsync(input).catch(() => undefined);}}>Publish once (not retained)</Button>\n {publish.data?.published && <Text size=\"xs\" c=\"green\">Published to {publish.data.topic}</Text>}\n </Stack>}\n </Stack>;\n}\n"
|
||||
},
|
||||
"dependencies": {
|
||||
"mqtt": "5.15.2",
|
||||
"supports-color": "8.1.1"
|
||||
},
|
||||
"connections": {
|
||||
"broker": {
|
||||
"kind": "service",
|
||||
"serviceType": "mqtt",
|
||||
"label": "MQTT broker",
|
||||
"description": "Settings: url, topics, optional publishTopics. Put username/password in encrypted credentials."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "MQTT topics"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.overview",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Composed homelab overview",
|
||||
"description": "Three independently authored components share one rich tile using SDK scopes for isolated state, commands, storage and handler routing.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"rack.systems": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"downloads.queue": {
|
||||
"kind": "query",
|
||||
"permission": "view",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection"
|
||||
],
|
||||
"properties": {
|
||||
"connection": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"secondary"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads.control": {
|
||||
"kind": "action",
|
||||
"permission": "modify",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connection",
|
||||
"operation"
|
||||
],
|
||||
"properties": {
|
||||
"connection": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"secondary"
|
||||
]
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pause",
|
||||
"resume",
|
||||
"pauseItem",
|
||||
"resumeItem",
|
||||
"deleteItem"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"guestAccess": true
|
||||
},
|
||||
"media.sessions": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"downloads.sources": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"downloads.controlBatch": {
|
||||
"kind": "action",
|
||||
"permission": "modify",
|
||||
"guestAccess": true,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"connections",
|
||||
"operation"
|
||||
],
|
||||
"properties": {
|
||||
"connections": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"secondary"
|
||||
]
|
||||
}
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pause",
|
||||
"resume"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"downloads-server.ts": "import type { BatchControl, BatchResult, ControlResult, DownloadConnection, DownloadControl, DownloadQueue, DownloadSource, SourceQueue } from \"./download-types\";\nimport type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\n\nconst declared:DownloadConnection[] = [\"client\",\"secondary\"];\nconst allowed = new Set([\"pause\",\"resume\",\"pauseItem\",\"resumeItem\",\"deleteItem\"]);\nasync function sources(context:WidgetServerContext):Promise<DownloadSource[]> {\n const {bindingNames,options} = await getWidgetServerContext(context);\n return declared.filter((connection) => bindingNames.includes(connection)).map((connection) => {\n let label = options.primaryLabel;\n if (connection === \"secondary\") label = options.secondaryLabel;\n if (typeof label !== \"string\" || !label.trim()) label = connection === \"client\" ? \"Primary client\" : \"Second client\";\n return {connection,label:String(label)};\n });\n}\nasync function source(context:WidgetServerContext,connection:DownloadConnection) {\n const result = (await sources(context)).find((candidate) => candidate.connection === connection);\n if (!result) throw new Error(\"Choose a declared, bound download client\");\n return result;\n}\nasync function queue(context:WidgetServerContext,connection:DownloadConnection):Promise<SourceQueue> {\n const selected = await source(context,connection);\n const result = await callWidgetIntegration<DownloadQueue>(context,{connection,capability:\"downloads.queue\",input:{limit:100}});\n return {...selected,data:result.data};\n}\nasync function control(context:WidgetServerContext,input:DownloadControl):Promise<ControlResult> {\n if (!allowed.has(input?.operation)) throw new Error(\"Unknown download operation\");\n await source(context,input.connection);\n if (input.operation === \"pause\" || input.operation === \"resume\") {\n await callWidgetIntegration(context,{connection:input.connection,capability:`downloads.${input.operation}`,input:{}});\n } else {\n if (typeof input.itemId !== \"string\" || !input.itemId) throw new Error(\"Choose a download from this client\");\n const current = await queue(context,input.connection);\n const item = current.data.items.find((candidate) => candidate.id === input.itemId);\n if (!item) throw new Error(\"The download is no longer in this client's queue; refresh and try again\");\n // Resolve the canonical item in the selected client. Never trust a browser-supplied client index or item object.\n await callWidgetIntegration(context,{connection:input.connection,capability:`downloads.${input.operation}`,input:{item,fromDisk:false}});\n }\n return {connection:input.connection,operation:input.operation,ok:true};\n}\nexport default defineWidgetServer({\n sources: (_input:unknown,context) => sources(context),\n queue: (input:{connection:DownloadConnection},context) => queue(context,input.connection),\n control: (input:DownloadControl,context) => control(context,input),\n controlBatch: async (input:BatchControl,context):Promise<BatchResult> => {\n if (![\"pause\",\"resume\"].includes(input?.operation)) throw new Error(\"Batch actions support pause or resume only\");\n if (!Array.isArray(input.connections) || !input.connections.length || input.connections.length > 2 || new Set(input.connections).size !== input.connections.length)\n throw new Error(\"Choose one or two different download clients\");\n const available = await sources(context);\n if (input.connections.some((connection) => !available.some((candidate) => candidate.connection === connection)))\n throw new Error(\"Every batch target must be a declared, bound client\");\n const results = await Promise.all(input.connections.map(async (connection):Promise<ControlResult> => {\n try { return await control(context,{connection,operation:input.operation}); }\n catch (error) { return {connection,operation:input.operation,ok:false,error:error instanceof Error ? error.message : String(error)}; }\n }));\n const succeeded = results.filter((result) => result.ok).length;\n return {results,succeeded,failed:results.length-succeeded};\n },\n});\n",
|
||||
"rack-server.ts": "import type { NativeResult, SystemRow } from \"./beszel-types\";\nimport { callWidgetIntegration, defineWidgetServer } from \"@homarr/widget-sdk/server\";\nexport default defineWidgetServer({ systems: (_input, context) => callWidgetIntegration<NativeResult<SystemRow[]>>(context, {connection:\"beszel\",capability:\"beszel.systems\"}) });",
|
||||
"server.ts": "import type { WidgetServerHandler } from \"@homarr/widget-sdk/server\";\nimport { defineWidgetServer } from \"@homarr/widget-sdk/server\";\nimport rack from \"./rack-server.ts\";\nimport downloads from \"./downloads-server.ts\";\nimport media from \"./media-server.ts\";\nconst prefix = (name:string, handlers:Record<string,WidgetServerHandler<never>>) => Object.fromEntries(Object.entries(handlers).map(([key, handler]) => [`${name}.${key}`, handler]));\nexport default defineWidgetServer({...prefix(\"rack\", rack), ...prefix(\"downloads\", downloads), ...prefix(\"media\", media)});",
|
||||
"download-types.ts": "export type DownloadConnection = \"client\" | \"secondary\";\nexport type DownloadOperation = \"pause\" | \"resume\" | \"pauseItem\" | \"resumeItem\" | \"deleteItem\";\nexport interface DownloadItem {\n id:string;name:string;state:string;progress:number;downSpeed?:number;\n index:number;type:\"torrent\"|\"usenet\"|\"miscellaneous\";size:number;time:number;\n}\nexport interface DownloadQueue {data:{items:DownloadItem[];status:{paused:boolean;rates:{down:number;up?:number}}}}\nexport interface DownloadSource {connection:DownloadConnection;label:string}\nexport interface SourceQueue extends DownloadSource {data:DownloadQueue[\"data\"]}\nexport interface DownloadControl {connection:DownloadConnection;operation:DownloadOperation;itemId?:string}\nexport interface BatchControl {connections:DownloadConnection[];operation:\"pause\"|\"resume\"}\nexport interface ControlResult {connection:DownloadConnection;operation:DownloadOperation;ok:boolean;error?:string}\nexport interface BatchResult {results:ControlResult[];succeeded:number;failed:number}\nexport interface DownloadOptions {primaryLabel:string;secondaryLabel:string}\n",
|
||||
"media.tsx": "import type { MediaSessions } from \"./media-types\";\nimport { Alert, Badge, Button, Card, Group, Progress, SimpleGrid, Stack, Text } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nexport default function Sessions() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const query = useWidgetQuery<MediaSessions>(\"sessions\", {}, { refetchInterval: 10000 });\n const rows = query.data?.data ?? [];\n const columns = Math.max(1, Math.floor(host.visibleWidth / 240));\n return <Stack p=\"md\" style={{overflow:\"auto\"}}><Text fw={600}>Now playing \u00b7 {rows.length}</Text>\n {query.error && <Alert color=\"red\">{query.error.message}<Button variant=\"subtle\" size=\"xs\" onClick={() => void query.refetch()}>Retry</Button></Alert>}\n <SimpleGrid cols={columns}>{rows.map((session) => { const playing = session.currentlyPlaying; const playback = playing?.playback; return <Card key={session.sessionId} withBorder padding=\"sm\"><Stack gap=\"xs\"><Group justify=\"space-between\"><Text size=\"sm\" fw={600}>{session.user.username}</Text><Badge size=\"xs\">{playback?.state ?? \"idle\"}</Badge></Group><Text>{playing?.name ?? session.sessionName}</Text>{playing?.episodeName && <Text size=\"xs\" c=\"dimmed\">{playing.episodeName}</Text>}{typeof playback?.durationMs === \"number\" && playback.durationMs > 0 && <Progress aria-label=\"Playback progress\" value={100 * (playback.positionMs ?? 0) / playback.durationMs} />}<Button size=\"compact-xs\" variant=\"light\" onClick={() => services.openDetail({title: session.sessionName, content: <Stack><Text>{session.user.username}</Text><Text>{playing?.name}</Text><Text size=\"sm\">{playing?.location ?? \"Unknown network\"} \u00b7 {playing?.metadata?.bitrateKbps ?? \"Unknown\"} kbps</Text><Text size=\"sm\">Video direct: {String(playing?.metadata?.transcoding?.isVideoDirect ?? \"unknown\")}</Text><Text size=\"sm\">Audio direct: {String(playing?.metadata?.transcoding?.isAudioDirect ?? \"unknown\")}</Text></Stack>})}>Session details</Button></Stack></Card>; })}</SimpleGrid>\n {!query.isPending && !query.error && query.data?.configured !== false && rows.length === 0 && <Text c=\"dimmed\">No active media sessions.</Text>}\n {query.data?.configured === false && <Text c=\"dimmed\" size=\"sm\">Bind a media server integration to show now-playing sessions.</Text>}\n </Stack>;\n}",
|
||||
"widget.tsx": "import { SimpleGrid } from \"@mantine/core\";\nimport { useWidgetHost, WidgetScope } from \"@homarr/widget-sdk\";\nimport Rack from \"./rack.tsx\";\nimport Downloads from \"./downloads.tsx\";\nimport Media from \"./media.tsx\";\nexport default function Overview() {\n const host = useWidgetHost();\n return <SimpleGrid cols={host.visibleWidth >= 900 ? 3 : 1} h=\"100%\" style={{overflow:\"auto\"}}>\n <WidgetScope name=\"rack\" handlerPrefix=\"rack.\"><Rack /></WidgetScope>\n <WidgetScope name=\"downloads\" handlerPrefix=\"downloads.\"><Downloads /></WidgetScope>\n <WidgetScope name=\"media\" handlerPrefix=\"media.\"><Media /></WidgetScope>\n </SimpleGrid>;\n}",
|
||||
"media-types.ts": "export type Camera = {name:string;path:string};\nexport type MediaOptions = {title:string;showSessions:boolean;cameras:Camera[];imageCount:number;snapshotInterval:number};\nexport interface GalleryItem {kind:\"camera\"|\"immich\";id:string;name:string;takenAt?:string;}\nexport type GalleryImageInput = Pick<GalleryItem,\"kind\"|\"id\">;\nexport interface ImmichAsset {id:string;originalFileName?:string;localDateTime?:string;fileCreatedAt?:string;}\nexport interface MediaSession {sessionId:string;sessionName:string;user:{username:string};currentlyPlaying:{name:string;episodeName?:string|null;location:\"lan\"|\"wan\"|null;playback:{state:\"playing\"|\"paused\"|\"buffering\"|null;positionMs:number|null;durationMs:number|null};metadata:{bitrateKbps:number|null;transcoding:{isVideoDirect:boolean;isAudioDirect:boolean}}|null}|null;}\nexport interface MediaSessions {data:MediaSession[];configured?:boolean;}\n",
|
||||
"media-server.ts": "import type { MediaSessions } from \"./media-types\";\nimport { callWidgetIntegration, defineWidgetServer } from \"@homarr/widget-sdk/server\";\nexport default defineWidgetServer({ sessions: (_input, context) => callWidgetIntegration<MediaSessions>(context, { connection: \"media\", capability: \"media.sessions\", input: {showOnlyPlaying: false} }) });",
|
||||
"rack.tsx": "import type { SystemRow, NativeResult } from \"./beszel-types\";\nimport { Alert, Button, Group, Stack, Text } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport \"./rack.css\";\nexport default function Rack() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const query = useWidgetQuery<NativeResult<SystemRow[]>>(\"systems\", {}, {refetchInterval: 15000});\n const [selected, setSelected] = useWidgetState(\"selected\", \"\");\n const systems = query.data?.data ?? [];\n const rows = host.displayMode === \"advanced\" ? systems : systems.slice(0, 8);\n const inspect = (system:SystemRow) => { setSelected(system.id); services.openDetail({title: system.name, content: <Stack><Text>{system.hostname} \u00b7 {system.osName}</Text><Text>CPU: {system.cpu.toFixed(1)}% \u00b7 Memory: {system.memory.toFixed(1)}% \u00b7 Disk: {system.disk.toFixed(1)}%</Text><Text>{system.cpuModel} \u00b7 {system.cores} cores</Text><Text>Uptime: {Math.floor(system.uptime / 3600)} hours</Text></Stack>}); };\n return <Stack className=\"reference-rack\" p=\"sm\" h=\"100%\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>Server rack</Text><Text size=\"xs\" c=\"dimmed\">{systems.length} systems</Text></Group>\n {query.error && <Alert color=\"red\">{query.error.message}<Button size=\"xs\" variant=\"subtle\" onClick={() => void query.refetch()}>Retry</Button></Alert>}\n <svg viewBox={`0 0 420 ${Math.max(90, rows.length * 64 + 24)}`} role=\"group\" aria-label=\"Beszel server rack\" style={{width:\"100%\", flexShrink:0}}>\n <rect x=\"1\" y=\"1\" width=\"418\" height={Math.max(88, rows.length * 64 + 22)} rx=\"8\" fill=\"var(--mantine-color-default)\" stroke=\"var(--mantine-color-default-border)\" />\n {rows.map((system, index) => <g key={system.id} role=\"button\" tabIndex={0} aria-label={`Inspect ${system.name}, ${system.status}`} onClick={() => inspect(system)} onKeyDown={(event) => { if (event.key === \"Enter\" || event.key === \" \") { event.preventDefault(); inspect(system); } }} className=\"reference-rack__unit\">\n <rect x=\"14\" y={12 + index * 64} width=\"392\" height=\"54\" rx=\"4\" fill=\"var(--mantine-color-body)\" stroke={selected === system.id ? \"var(--mantine-color-blue-6)\" : \"var(--mantine-color-default-border)\"} />\n <circle cx=\"31\" cy={39 + index * 64} r=\"5\" fill={system.status === \"up\" ? \"var(--mantine-color-teal-6)\" : \"var(--mantine-color-red-6)\"} />\n <text x=\"47\" y={34 + index * 64} fill=\"var(--mantine-color-text)\" fontSize=\"14\">{system.name.slice(0, 35)}</text>\n <text x=\"47\" y={53 + index * 64} fill=\"var(--mantine-color-dimmed)\" fontSize=\"11\">CPU {system.cpu.toFixed(1)}% \u00b7 RAM {system.memory.toFixed(1)}% \u00b7 {system.status}</text>\n <rect x=\"345\" y={26 + index * 64} width=\"44\" height=\"24\" rx=\"3\" fill=\"var(--mantine-color-dark-5)\" />\n {[0,1,2,3].map((slot) => <path key={slot} d={`M${350 + slot * 10},${30 + index * 64}v16`} stroke=\"var(--mantine-color-gray-6)\" />)}\n </g>)}\n </svg>\n {!query.isPending && !query.error && !systems.length && <Text c=\"dimmed\">No Beszel systems are available.</Text>}\n </Stack>;\n}",
|
||||
"beszel-types.ts": "// Public result fields used from the Homarr Beszel capability adapter.\nexport interface SystemRow {\n id:string; name:string; status:\"up\"|\"down\"|\"paused\"|\"pending\";\n cpu:number; memory:number; disk:number; gpu:number; netBytes:number; uptime:number;\n hostname:string; cpuModel:string; cores:number; osName:string; agentVersion:string;\n}\nexport interface AttributedSystem extends SystemRow {connection:string;rowKey:string}\nexport interface NativeResult<T> {data:T;timestamp?:string}\nexport interface SystemStats {cpu:number;mu:number;mb:number;du:number;dio?:[number,number];dr?:number;dw?:number;b?:[number,number];ns?:number;nr?:number}\nexport interface ContainerStats {n:string;c:number;m:number;b?:[number,number];ns?:number;nr?:number}\nexport interface StatsRecord<T> {id:string;created:string;stats:T}\nexport type SystemRecord = StatsRecord<SystemStats>;\nexport type ContainerRecord = StatsRecord<ContainerStats[]>;\nexport interface StatsResult {systemStats:SystemRecord[];containerStats:ContainerRecord[]}\nexport type LiveStatsEvent = {type:\"system_stats\";record:SystemRecord}|{type:\"container_stats\";record:ContainerRecord};\nexport interface StatsInput {connection?:string;systemId:string;timePeriod?:string;includeDocker?:boolean}\n",
|
||||
"downloads.tsx": "import type { BatchControl, BatchResult, DownloadConnection, DownloadSource } from \"./download-types\";\nimport { Alert, Button, Group, MultiSelect, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetCommands, useWidgetHost, useWidgetQuery, useWidgetState } from \"@homarr/widget-sdk\";\nimport { DownloadSourceRegion } from \"./download-source\";\nexport default function Downloads() {\n const host = useWidgetHost();\n const sources = useWidgetQuery<DownloadSource[]>(\"sources\",{}, {staleTime:60000});\n const capabilities = useWidgetCapabilities();\n const batch = useWidgetAction<BatchResult,BatchControl>(\"controlBatch\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const [sort,setSort] = useWidgetState(\"sort\",\"queue\");\n const [selection,setSelection] = useWidgetState<DownloadConnection[]>(\"batch-clients\",[]);\n const available = sources.data ?? [];\n const targets = selection.filter((name) => available.some((source) => source.connection === name));\n const all = available.map((source) => source.connection);\n const canRun = (input:BatchControl) => input.connections.length > 0 && !host.isEditMode && !batch.isPending && capabilities.canRun(\"controlBatch\",input);\n const run = (input:BatchControl) => {void batch.mutateAsync(input).catch(() => undefined);};\n const pauseAll:BatchControl = {connections:all,operation:\"pause\"};\n const resumeAll:BatchControl = {connections:all,operation:\"resume\"};\n const pauseSelected:BatchControl = {connections:targets,operation:\"pause\"};\n const resumeSelected:BatchControl = {connections:targets,operation:\"resume\"};\n useWidgetCommands([{id:\"pause-all\",label:\"Pause all download clients\",disabled:!canRun(pauseAll),run:() => run(pauseAll)},{id:\"resume-all\",label:\"Resume all download clients\",disabled:!canRun(resumeAll),run:() => run(resumeAll)}]);\n return <Stack p=\"sm\" gap=\"sm\" h=\"100%\" style={{overflow:\"auto\"}}>\n <Text fw={600}>Downloads \u00b7 {available.length} clients</Text>\n {sources.error && <Alert color=\"red\">{sources.error.message}<Button size=\"xs\" variant=\"subtle\" onClick={() => void sources.refetch()}>Retry setup</Button></Alert>}\n {host.displayMode === \"advanced\" && <>\n <Group><TextInput label=\"Filter downloads\" value={filter} onChange={(event) => setFilter(event.currentTarget.value)} /><Select label=\"Sort\" value={sort} onChange={(value) => setSort(value ?? \"queue\")} data={[{value:\"queue\",label:\"Queue\"},{value:\"progress\",label:\"Progress\"},{value:\"name\",label:\"Name\"}]} /></Group>\n <Group gap=\"xs\"><Button size=\"compact-xs\" disabled={!canRun(pauseAll)} onClick={() => run(pauseAll)}>Pause all clients</Button><Button size=\"compact-xs\" disabled={!canRun(resumeAll)} onClick={() => run(resumeAll)}>Resume all clients</Button></Group>\n {available.length > 1 && <Group align=\"end\"><MultiSelect label=\"Batch targets\" data={available.map((source) => ({value:source.connection,label:source.label}))} value={selection} onChange={(values) => setSelection(all.filter((connection) => values.includes(connection)))} /><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(pauseSelected)} onClick={() => run(pauseSelected)}>Pause selected</Button><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(resumeSelected)} onClick={() => run(resumeSelected)}>Resume selected</Button></Group>}\n </>}\n {batch.isPending && <Text size=\"xs\" c=\"dimmed\" role=\"status\">Updating selected clients\u2026</Text>}\n {batch.error && <Alert color=\"red\">{batch.error.message}</Alert>}\n {batch.data && <Alert color={batch.data.failed ? \"yellow\" : \"green\"} title={`${batch.data.succeeded} succeeded \u00b7 ${batch.data.failed} failed`}>\n {batch.data.results.map((result) => <Text size=\"xs\" key={result.connection}>{available.find((source) => source.connection === result.connection)?.label ?? result.connection}: {result.ok ? `${result.operation} completed` : result.error}</Text>)}\n </Alert>}\n {available.map((source) => <DownloadSourceRegion key={source.connection} source={source} filter={filter} sort={sort} />)}\n {!sources.isPending && !sources.error && available.length === 0 && <Text c=\"dimmed\">Bind a download client to get started.</Text>}\n </Stack>;\n}\n",
|
||||
"rack.css": ".reference-rack__unit { cursor: pointer; outline: none; }\n.reference-rack__unit:focus-visible > rect:first-child { stroke: var(--mantine-color-blue-6); stroke-width: 3; }\n.reference-rack__unit:hover > rect:first-child { stroke: var(--mantine-color-blue-5); }\n",
|
||||
"download-source.tsx": "import type { DownloadControl, DownloadItem, DownloadSource, SourceQueue, ControlResult } from \"./download-types\";\nimport { Alert, Badge, Button, Group, Progress, Stack, Text } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetHost, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nconst rate = (value:number|undefined) => `${((value ?? 0)/1048576).toFixed(1)} MiB/s`;\nexport function DownloadSourceRegion({source,filter,sort}:{source:DownloadSource;filter:string;sort:string}) {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const capabilities = useWidgetCapabilities();\n const query = useWidgetQuery<SourceQueue>(\"queue\",{connection:source.connection},{refetchInterval:10000});\n const action = useWidgetAction<ControlResult,DownloadControl>(\"control\");\n const status = query.data?.data.status;\n const error = query.error ?? action.error;\n const run = (input:DownloadControl) => {void action.mutateAsync(input).catch(() => undefined);};\n const canRun = (input:DownloadControl) => !host.isEditMode && !action.isPending && capabilities.canRun(\"control\",input);\n const remove = async (item:DownloadItem) => {\n const input:DownloadControl = {connection:source.connection,operation:\"deleteItem\",itemId:item.id};\n if (!canRun(input)) return;\n if (await services.confirm({title:\"Remove download\",message:`Remove ${item.name} from ${source.label}? Downloaded files will be kept.`,destructive:true})) run(input);\n };\n const rows = query.data?.data.items ?? [];\n let items = rows.filter((item) => item.name.toLocaleLowerCase().includes(filter.toLocaleLowerCase()));\n if (sort === \"progress\") items = items.toSorted((a,b) => b.progress-a.progress);\n if (sort === \"name\") items = items.toSorted((a,b) => a.name.localeCompare(b.name));\n if (host.displayMode === \"compact\") items = items.slice(0,3);\n const pause:DownloadControl = {connection:source.connection,operation:\"pause\"};\n const resume:DownloadControl = {connection:source.connection,operation:\"resume\"};\n return <Stack gap=\"xs\" component=\"section\" aria-label={source.label} style={{border:\"1px solid var(--mantine-color-default-border)\",borderRadius:8,padding:10}}>\n <Group justify=\"space-between\"><Text size=\"sm\" fw={600}>{source.label} \u00b7 {rows.length}</Text>{status && <Badge color={status.paused ? \"yellow\" : \"blue\"}>{status.paused ? \"Paused\" : rate(status.rates.down)}</Badge>}</Group>\n {error && <Alert color=\"yellow\" title={source.label}>{error.message}<Button size=\"compact-xs\" variant=\"subtle\" onClick={() => {action.reset();void query.refetch();}}>Retry this client</Button></Alert>}\n {action.isPending && <Text size=\"xs\" c=\"dimmed\" role=\"status\">Updating this client\u2026</Text>}\n {query.isPending && <Text size=\"xs\" c=\"dimmed\">Loading this client's queue\u2026</Text>}\n {host.displayMode === \"advanced\" && <Group gap=\"xs\"><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(pause)} onClick={() => run(pause)}>Pause this client</Button><Button size=\"compact-xs\" variant=\"light\" disabled={!canRun(resume)} onClick={() => run(resume)}>Resume this client</Button></Group>}\n {items.map((item) => {\n const toggle:DownloadControl = {connection:source.connection,operation:item.state === \"paused\" ? \"resumeItem\" : \"pauseItem\",itemId:item.id};\n const deletion:DownloadControl = {connection:source.connection,operation:\"deleteItem\",itemId:item.id};\n return <Stack key={`${source.connection}:${item.id}`} gap={4}>\n <Group justify=\"space-between\" wrap=\"nowrap\"><Text size=\"sm\" truncate>{item.name}</Text><Badge size=\"xs\">{item.state}</Badge></Group>\n <Progress aria-label={`${source.label}: ${item.name} progress`} value={item.progress*100} />\n <Group justify=\"space-between\"><Text size=\"xs\" c=\"dimmed\">{Math.round(item.progress*100)}% \u00b7 {rate(item.downSpeed)}</Text>{host.displayMode === \"advanced\" && <Group gap=\"xs\"><Button size=\"compact-xs\" variant=\"subtle\" disabled={!canRun(toggle)} onClick={() => run(toggle)}>{item.state === \"paused\" ? \"Resume\" : \"Pause\"}</Button><Button size=\"compact-xs\" color=\"red\" variant=\"subtle\" disabled={!canRun(deletion)} onClick={() => void remove(item)}>Remove</Button></Group>}</Group>\n </Stack>;\n })}\n {!query.isPending && !query.error && items.length === 0 && <Text size=\"xs\" c=\"dimmed\">No downloads match this client view.</Text>}\n {action.data?.ok && <Text size=\"xs\" c=\"green\">{source.label}: {action.data.operation} completed</Text>}\n </Stack>;\n}\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"beszel": {
|
||||
"kind": "integration",
|
||||
"label": "Beszel integration"
|
||||
},
|
||||
"client": {
|
||||
"kind": "integration",
|
||||
"label": "Primary download client"
|
||||
},
|
||||
"media": {
|
||||
"kind": "integration",
|
||||
"label": "Media server"
|
||||
},
|
||||
"secondary": {
|
||||
"kind": "integration",
|
||||
"label": "Second download client",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"primaryLabel": {
|
||||
"control": "text",
|
||||
"label": "Primary client label",
|
||||
"default": "Primary client"
|
||||
},
|
||||
"secondaryLabel": {
|
||||
"control": "text",
|
||||
"label": "Second client label",
|
||||
"default": "Second client"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.rack",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Interactive server rack",
|
||||
"description": "A keyboard-accessible SVG rack backed by real Beszel inventory, with selectable systems and host-native detail views.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"systems": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"server.ts": "import type { NativeResult, SystemRow } from \"./beszel-types\";\nimport { callWidgetIntegration, defineWidgetServer } from \"@homarr/widget-sdk/server\";\nexport default defineWidgetServer({ systems: (_input, context) => callWidgetIntegration<NativeResult<SystemRow[]>>(context, {connection:\"beszel\",capability:\"beszel.systems\"}) });",
|
||||
"widget.tsx": "import type { SystemRow, NativeResult } from \"./beszel-types\";\nimport { Alert, Button, Group, Stack, Text } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport \"./rack.css\";\nexport default function Rack() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const query = useWidgetQuery<NativeResult<SystemRow[]>>(\"systems\", {}, {refetchInterval: 15000});\n const [selected, setSelected] = useWidgetState(\"selected\", \"\");\n const systems = query.data?.data ?? [];\n const rows = host.displayMode === \"advanced\" ? systems : systems.slice(0, 8);\n const inspect = (system:SystemRow) => { setSelected(system.id); services.openDetail({title: system.name, content: <Stack><Text>{system.hostname} \u00b7 {system.osName}</Text><Text>CPU: {system.cpu.toFixed(1)}% \u00b7 Memory: {system.memory.toFixed(1)}% \u00b7 Disk: {system.disk.toFixed(1)}%</Text><Text>{system.cpuModel} \u00b7 {system.cores} cores</Text><Text>Uptime: {Math.floor(system.uptime / 3600)} hours</Text></Stack>}); };\n return <Stack className=\"reference-rack\" p=\"sm\" h=\"100%\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>Server rack</Text><Text size=\"xs\" c=\"dimmed\">{systems.length} systems</Text></Group>\n {query.error && <Alert color=\"red\">{query.error.message}<Button size=\"xs\" variant=\"subtle\" onClick={() => void query.refetch()}>Retry</Button></Alert>}\n <svg viewBox={`0 0 420 ${Math.max(90, rows.length * 64 + 24)}`} role=\"group\" aria-label=\"Beszel server rack\" style={{width:\"100%\", flexShrink:0}}>\n <rect x=\"1\" y=\"1\" width=\"418\" height={Math.max(88, rows.length * 64 + 22)} rx=\"8\" fill=\"var(--mantine-color-default)\" stroke=\"var(--mantine-color-default-border)\" />\n {rows.map((system, index) => <g key={system.id} role=\"button\" tabIndex={0} aria-label={`Inspect ${system.name}, ${system.status}`} onClick={() => inspect(system)} onKeyDown={(event) => { if (event.key === \"Enter\" || event.key === \" \") { event.preventDefault(); inspect(system); } }} className=\"reference-rack__unit\">\n <rect x=\"14\" y={12 + index * 64} width=\"392\" height=\"54\" rx=\"4\" fill=\"var(--mantine-color-body)\" stroke={selected === system.id ? \"var(--mantine-color-blue-6)\" : \"var(--mantine-color-default-border)\"} />\n <circle cx=\"31\" cy={39 + index * 64} r=\"5\" fill={system.status === \"up\" ? \"var(--mantine-color-teal-6)\" : \"var(--mantine-color-red-6)\"} />\n <text x=\"47\" y={34 + index * 64} fill=\"var(--mantine-color-text)\" fontSize=\"14\">{system.name.slice(0, 35)}</text>\n <text x=\"47\" y={53 + index * 64} fill=\"var(--mantine-color-dimmed)\" fontSize=\"11\">CPU {system.cpu.toFixed(1)}% \u00b7 RAM {system.memory.toFixed(1)}% \u00b7 {system.status}</text>\n <rect x=\"345\" y={26 + index * 64} width=\"44\" height=\"24\" rx=\"3\" fill=\"var(--mantine-color-dark-5)\" />\n {[0,1,2,3].map((slot) => <path key={slot} d={`M${350 + slot * 10},${30 + index * 64}v16`} stroke=\"var(--mantine-color-gray-6)\" />)}\n </g>)}\n </svg>\n {!query.isPending && !query.error && !systems.length && <Text c=\"dimmed\">No Beszel systems are available.</Text>}\n </Stack>;\n}",
|
||||
"beszel-types.ts": "// Public result fields used from the Homarr Beszel capability adapter.\nexport interface SystemRow {\n id:string; name:string; status:\"up\"|\"down\"|\"paused\"|\"pending\";\n cpu:number; memory:number; disk:number; gpu:number; netBytes:number; uptime:number;\n hostname:string; cpuModel:string; cores:number; osName:string; agentVersion:string;\n}\nexport interface AttributedSystem extends SystemRow {connection:string;rowKey:string}\nexport interface NativeResult<T> {data:T;timestamp?:string}\nexport interface SystemStats {cpu:number;mu:number;mb:number;du:number;dio?:[number,number];dr?:number;dw?:number;b?:[number,number];ns?:number;nr?:number}\nexport interface ContainerStats {n:string;c:number;m:number;b?:[number,number];ns?:number;nr?:number}\nexport interface StatsRecord<T> {id:string;created:string;stats:T}\nexport type SystemRecord = StatsRecord<SystemStats>;\nexport type ContainerRecord = StatsRecord<ContainerStats[]>;\nexport interface StatsResult {systemStats:SystemRecord[];containerStats:ContainerRecord[]}\nexport type LiveStatsEvent = {type:\"system_stats\";record:SystemRecord}|{type:\"container_stats\";record:ContainerRecord};\nexport interface StatsInput {connection?:string;systemId:string;timePeriod?:string;includeDocker?:boolean}\n",
|
||||
"rack.css": ".reference-rack__unit { cursor: pointer; outline: none; }\n.reference-rack__unit:focus-visible > rect:first-child { stroke: var(--mantine-color-blue-6); stroke-width: 3; }\n.reference-rack__unit:hover > rect:first-child { stroke: var(--mantine-color-blue-5); }\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"beszel": {
|
||||
"kind": "integration",
|
||||
"label": "Beszel integration"
|
||||
}
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.sonarr",
|
||||
"version": "1.1.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.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"overview": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"searchEpisodes": {
|
||||
"kind": "action",
|
||||
"permission": "modify",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["connection", "episodeIds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"connection": {
|
||||
"enum": ["sonarr", "secondary"]
|
||||
},
|
||||
"episodeIds": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"sonarr": {
|
||||
"kind": "integration",
|
||||
"label": "Sonarr integration"
|
||||
},
|
||||
"secondary": {
|
||||
"kind": "integration",
|
||||
"label": "Second Sonarr integration",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"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",
|
||||
"default": 7,
|
||||
"min": 1,
|
||||
"max": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.sqlite",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "SQLite and local status",
|
||||
"description": "Read-only named SQLite queries, rich data tables and optional local JSON status. No HTTP API required.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx",
|
||||
"server": "server.ts"
|
||||
},
|
||||
"handlers": {
|
||||
"catalog": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"rows": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
},
|
||||
"status": {
|
||||
"kind": "query",
|
||||
"permission": "view"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"server.ts": "import type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\ntype NamedQuery = {name:string;label?:string;sql:string};\ntype Cell = null|string|number;\nimport { DatabaseSync } from \"node:sqlite\";\nimport { open } from \"node:fs/promises\";\nimport { defineWidgetServer, getWidgetConnection, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\n\nconst databaseSettings = async (context:WidgetServerContext) => {\n const {configuration} = await getWidgetConnection(context,{connection:\"database\",serviceType:\"sqlite\"});\n const settings = configuration.settings ?? {};\n if (typeof settings.path !== \"string\" || !settings.path.trim()) throw new Error(\"Set path on the SQLite service connection to a mounted database file\");\n if (!Array.isArray(settings.queries) || !settings.queries.length) throw new Error(\"Configure named queries on the SQLite service: queries:[{name,label,sql}]\");\n const queries = settings.queries.filter((query:unknown): query is NamedQuery => Boolean(query && typeof query === \"object\" && \"name\" in query && typeof query.name === \"string\" && \"sql\" in query && typeof query.sql === \"string\" && (!(\"label\" in query) || typeof query.label === \"string\")));\n if (!queries.length) throw new Error(\"At least one named SQL query is required\");\n return {path:settings.path,queries};\n};\nconst jsonValue = (value:null|string|number|bigint|Uint8Array):Cell => {\n if (typeof value === \"bigint\") return String(value);\n if (value instanceof Uint8Array) return `[${value.byteLength} binary bytes]`;\n return value;\n};\nexport default defineWidgetServer({\n catalog: async (_input,context) => {\n const settings = await databaseSettings(context);\n return settings.queries.map((query) => ({value:query.name,label:query.label || query.name}));\n },\n rows: async (input:{name?:string},context) => {\n const settings = await databaseSettings(context);\n const query = settings.queries.find((query) => query.name === input?.name);\n if (!query) throw new Error(\"Choose a configured query\");\n const {options} = await getWidgetServerContext(context);\n const limit = Math.max(1,Math.min(200,Number(options.rowLimit)||100));\n const database = new DatabaseSync(settings.path,{readOnly:true,timeout:1000,allowExtension:false});\n try {\n const statement = database.prepare(query.sql);\n statement.setReadBigInts(true);\n const rows: Record<string,Cell>[] = [];\n let truncated = false;\n for (const row of statement.iterate()) {\n context.signal.throwIfAborted();\n if (rows.length >= limit) { truncated = true; break; }\n rows.push(Object.fromEntries(Object.entries(row).map(([key,value]) => [key,jsonValue(value)])));\n }\n return {rows,columns:statement.columns().map((column) => column.name),truncated,updatedAt:new Date().toISOString()};\n } finally { database.close(); }\n },\n status: async (_input,context) => {\n const {bindingNames} = await getWidgetServerContext(context);\n if (!bindingNames.includes(\"status\")) return {configured:false};\n const {configuration} = await getWidgetConnection(context,{connection:\"status\",serviceType:\"file\"});\n const path = configuration.settings?.path;\n if (typeof path !== \"string\") throw new Error(\"Set path on the local file connection\");\n const file = await open(path,\"r\");\n try {\n const stat = await file.stat();\n if (!stat.isFile() || stat.size > 524288) throw new Error(\"Choose a regular JSON status file under 512 KiB\");\n const bytes = Buffer.alloc(524289);\n const {bytesRead} = await file.read(bytes,0,bytes.length,0);\n if (bytesRead > 524288) throw new Error(\"The status file exceeds 512 KiB\");\n return {configured:true,value:JSON.parse(bytes.subarray(0,bytesRead).toString(\"utf8\")),updatedAt:stat.mtime.toISOString()};\n } finally { await file.close(); }\n },\n});\n",
|
||||
"README.md": "# SQLite and local status\n\nCreate a Service connection with serviceType `sqlite`. Nonsecret settings:\n\n```json\n{\"path\":\"/data/app.sqlite\",\"queries\":[{\"name\":\"recent\",\"label\":\"Recent jobs\",\"sql\":\"SELECT id, status, created_at FROM jobs ORDER BY created_at DESC LIMIT 100\"}]}\n```\n\nThe path is resolved inside the Homarr process/container. Mount the application's database (and SQLite WAL/SHM companions when applicable) read-only and ensure the Homarr user can read them. Prefer an application-supported backup/snapshot for a busy database. This adapter uses Node 24's built-in node:sqlite; no npm or HTTP connection is needed. Each invocation opens read-only and closes its own connection. Only owner-configured named queries are accepted from viewers, and results are bounded. Add an indexed LIMIT to expensive queries; the worker deadline cancels excessively slow handlers.\n\nOptionally bind a serviceType `file` connection to status with settings {\"path\":\"/data/status.json\"}. It reads bounded JSON, checks the file size, and closes the descriptor. The files and SQL remain local connection settings and are not exported with the widget.\n\nDiagnose checks file readability. Run preview to verify SQLite format, SQL and JSON parsing. Source: https://nodejs.org/docs/latest-v24.x/api/sqlite.html\n",
|
||||
"widget.tsx": "type Row = Record<string,null|string|number> & {__row:number};\nimport type server from \"./server\";\nimport { Alert, Badge, Button, Group, Select, Stack, Text } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetServices, useWidgetOptions, useWidgetQuery, useWidgetState } from \"@homarr/widget-sdk\";\nimport { HomarrDataTable } from \"@homarr/widget-sdk/ui\";\nexport default function DatabaseDashboard() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const options = useWidgetOptions<{title:string;rowLimit:number}>();\n const [selected,setSelected] = useWidgetState(\"query\",\"\");\n const catalog = useWidgetQuery<Awaited<ReturnType<typeof server.catalog>>>(\"catalog\",{}, {staleTime:60000});\n const name = selected || catalog.data?.[0]?.value || \"\";\n const rows = useWidgetQuery<Awaited<ReturnType<typeof server.rows>>>(\"rows\",{name},{enabled:Boolean(name),refetchInterval:30000});\n const status = useWidgetQuery<Awaited<ReturnType<typeof server.status>>>(\"status\",{}, {refetchInterval:30000});\n const error = catalog.error || rows.error;\n const records:Row[] = (rows.data?.rows ?? []).map((row,index) => ({...row,__row:index}));\n let visible = records.slice(0,10);\n if (host.displayMode === \"advanced\") visible = records;\n return <Stack h=\"100%\" gap=\"xs\" p=\"sm\">\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Badge variant=\"light\">Read only</Badge></Group>\n <Select aria-label=\"Configured database query\" data={catalog.data ?? []} value={name || null} onChange={(value) => setSelected(value || \"\")} />\n {error && <Alert color=\"red\">{error.message}</Alert>}\n <div style={{flex:1,minHeight:0}}><HomarrDataTable<Row> isEditMode={host.isEditMode} cellPadding=\"6px\" idAccessor=\"__row\" records={visible} fetching={rows.isFetching} minHeight={100} columns={(rows.data?.columns ?? []).map((key) => ({accessor:key,title:key,resizable:true,render:(row:Row) => <Text size=\"xs\" lineClamp={2}>{String(row[key] ?? \"\u2014\")}</Text>}))} noRecordsText=\"This query has no rows\" /></div>\n <Group justify=\"space-between\"><Text size=\"xs\" c=\"dimmed\">{records.length} rows{rows.data?.truncated ? \" \u00b7 row limit reached\" : \"\"}</Text>{host.displayMode !== \"advanced\" && <Button size=\"compact-xs\" variant=\"subtle\" onClick={() => services.openAdvanced()}>Explore rows</Button>}</Group>\n {status.error && <Alert color=\"yellow\">Status file: {status.error.message}</Alert>}\n {status.data?.configured && <Text component=\"pre\" size=\"xs\" style={{whiteSpace:\"pre-wrap\",maxHeight:100,overflow:\"auto\"}}>{JSON.stringify(status.data.value,null,2).slice(0,4000)}</Text>}\n </Stack>;\n}\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {
|
||||
"database": {
|
||||
"kind": "service",
|
||||
"serviceType": "sqlite",
|
||||
"label": "SQLite database",
|
||||
"description": "Local settings: path and queries:[{name,label,sql}]. The database opens read-only."
|
||||
},
|
||||
"status": {
|
||||
"kind": "service",
|
||||
"serviceType": "file",
|
||||
"label": "Local JSON status file",
|
||||
"description": "Local settings: path to a JSON file mounted in the Homarr container.",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Application database"
|
||||
},
|
||||
"rowLimit": {
|
||||
"control": "number",
|
||||
"label": "Maximum rows",
|
||||
"default": 100,
|
||||
"min": 1,
|
||||
"max": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "homarr-widget-package-v3",
|
||||
"manifest": {
|
||||
"id": "homarr.reference.timer",
|
||||
"version": "1.0.0",
|
||||
"sdkVersion": "1",
|
||||
"name": "Focus timer",
|
||||
"description": "A source-free deadline timer. Pausing, view changes and hidden tabs preserve the correct remaining time.",
|
||||
"entrypoints": {
|
||||
"tile": "widget.tsx",
|
||||
"advanced": "widget.tsx"
|
||||
},
|
||||
"handlers": {}
|
||||
},
|
||||
"files": {
|
||||
"widget.tsx": "import { Alert, Button, Group, NumberInput, Progress, Stack, Text } from \"@mantine/core\";\nimport { useWidgetClock, useWidgetCommands, useWidgetHost, useWidgetOptions, useWidgetState } from \"@homarr/widget-sdk\";\nexport default function Timer() {\n const host = useWidgetHost();\n const options = useWidgetOptions<{title:string;minutes:number}>();\n const now = useWidgetClock(100);\n const [minutes, setMinutes] = useWidgetState(\"minutes\", options.minutes);\n const [timer, setTimer] = useWidgetState<{remaining:number;deadline:number|null;duration:number}>(\"timer\", { remaining: options.minutes * 60000, deadline: null, duration: options.minutes * 60000 });\n const remaining = timer.deadline === null || now === null ? timer.remaining : Math.max(0, timer.deadline - now);\n const running = timer.deadline !== null && remaining > 0;\n const start = () => setTimer({ remaining, duration: timer.duration, deadline: Date.now() + remaining });\n const pause = () => setTimer({ ...timer, remaining, deadline: null });\n const reset = () => setTimer({ remaining: minutes * 60000, duration: minutes * 60000, deadline: null });\n useWidgetCommands([{ id: \"toggle\", label: running ? \"Pause timer\" : \"Start timer\", disabled: remaining <= 0, run: running ? pause : start }, { id: \"reset\", label: \"Reset timer\", run: reset }]);\n const seconds = Math.ceil(remaining / 1000);\n return <Stack h=\"100%\" justify=\"center\" p=\"md\" gap=\"sm\">\n <Text c=\"dimmed\" size=\"sm\">{options.title}</Text>\n <Text ff=\"monospace\" fw={700} fz={host.displayMode === \"advanced\" ? \"4rem\" : \"2rem\"} aria-live={remaining === 0 ? \"polite\" : \"off\"}>{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, \"0\")}</Text>\n <Progress value={timer.duration ? 100 * (1 - remaining / timer.duration) : 0} aria-label=\"Elapsed timer duration\" />\n {remaining === 0 && <Alert color=\"teal\">Timer complete</Alert>}\n <Group><Button disabled={host.isEditMode || remaining <= 0} onClick={running ? pause : start}>{running ? \"Pause\" : \"Start\"}</Button><Button variant=\"light\" disabled={host.isEditMode} onClick={reset}>Reset</Button></Group>\n {host.displayMode === \"advanced\" && <NumberInput label=\"Minutes for next reset\" min={1} max={1440} value={minutes} onChange={(value) => { if (typeof value === \"number\") setMinutes(value); }} />}\n </Stack>;\n}\n"
|
||||
},
|
||||
"dependencies": {},
|
||||
"connections": {},
|
||||
"options": {
|
||||
"title": {
|
||||
"control": "text",
|
||||
"label": "Title",
|
||||
"default": "Focus"
|
||||
},
|
||||
"minutes": {
|
||||
"control": "number",
|
||||
"label": "Minutes",
|
||||
"default": 25,
|
||||
"min": 1,
|
||||
"max": 1440
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user