142 lines
28 KiB
JSON
142 lines
28 KiB
JSON
{
|
|
"$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"
|
|
}
|
|
}
|
|
}
|