feat(custom-widgets): add extensible widget platform

This commit is contained in:
ajnart committed 2026-09-12 13:54:05 +02:00
1 parent 6cca757493
commit 8f152f1929
317 files changed
+32326 -1788

No files matched your search

+360
View File
@@ -0,0 +1,360 @@
"use client";
import type { Context, Dispatch, PropsWithChildren, SetStateAction } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { hashKey, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
WidgetCommand,
WidgetHostContext,
WidgetHostServices,
WidgetStateStore,
WidgetStorageScope,
} from "./shared";
export type * from "./shared";
export { parseLocalWidgetState, serializeLocalWidgetState, useLocalWidgetState } from "./local-state";
interface WidgetRuntime {
context: WidgetHostContext;
services: WidgetHostServices;
options: Record<string, unknown>;
}
// The authorized module loader and Next route chunks must share one context even after HMR.
// Compiled package modules can outlive the chunk that originally supplied their SDK hooks.
const contextKey = Symbol.for("homarr.widget.context.v1");
const contextRegistry = globalThis as typeof globalThis & { [contextKey]?: Context<WidgetRuntime | null> };
const WidgetContext = (contextRegistry[contextKey] ??= createContext<WidgetRuntime | null>(null));
export function WidgetHostProvider({ children, ...runtime }: PropsWithChildren<WidgetRuntime>) {
return <WidgetContext.Provider value={runtime}>{children}</WidgetContext.Provider>;
}
/** Compose independently authored components without colliding state, storage, commands or query caches. */
export function WidgetScope({
name,
handlerPrefix = "",
options,
children,
}: PropsWithChildren<{
name: string;
handlerPrefix?: string;
options?: Record<string, unknown>;
}>) {
const runtime = useWidgetRuntime();
const parent = runtime.services;
const services = useMemo<WidgetHostServices>(() => {
const operation = (input: Parameters<WidgetHostServices["transport"]["query"]>[0]) => ({
...input,
name: input.name.startsWith("$") ? input.name : `${handlerPrefix}${input.name}`,
});
return {
...parent,
state: {
get: (key) => parent.state.get(`${name}:${key}`),
set: (key, value) => parent.state.set(`${name}:${key}`, value),
subscribe: parent.state.subscribe,
},
registerCommands: (commands) =>
parent.registerCommands(commands.map((command) => ({ ...command, id: `${name}:${command.id}` }))),
transport: {
query: (input, signal) => parent.transport.query(operation(input), signal),
action: (input) => parent.transport.action(operation(input)),
subscribe: (input, observer) => parent.transport.subscribe(operation(input), observer),
storageGet: (input, signal) => parent.transport.storageGet({ ...input, key: `${name}:${input.key}` }, signal),
storageSet: (input) => parent.transport.storageSet({ ...input, key: `${name}:${input.key}` }),
},
};
}, [handlerPrefix, name, parent]);
const scope = `${runtime.context.scope ?? ""}/${name}`;
return (
<WidgetContext.Provider
value={{
context: { ...runtime.context, scope, handlerPrefix: `${runtime.context.handlerPrefix ?? ""}${handlerPrefix}` },
services,
options: options ?? runtime.options,
}}
>
{children}
</WidgetContext.Provider>
);
}
function useWidgetRuntime() {
const runtime = useContext(WidgetContext);
if (!runtime) throw new Error("Widget SDK hooks require a Homarr widget host");
return runtime;
}
export function useWidgetHost() {
return useWidgetRuntime().context;
}
export function useWidgetServices() {
return useWidgetRuntime().services;
}
export function useWidgetOptions<T extends Record<string, unknown> = Record<string, unknown>>() {
return useWidgetRuntime().options as T;
}
function queryScope(context: WidgetHostContext) {
return ["custom-widget-sdk", context.revisionId, context.itemId ?? context.previewId, context.userId] as const;
}
interface WidgetQueryOptions {
enabled?: boolean;
refetchInterval?: number | false;
staleTime?: number;
}
export function useWidgetQuery<T = unknown>(name: string, input?: unknown, options: WidgetQueryOptions = {}) {
const { context, services } = useWidgetRuntime();
const itemId = context.itemId ?? context.previewId;
const query = useQuery<T>({
queryKey: [...queryScope(context), "query", context.scope, name, input],
queryFn: ({ signal }) => {
if (!itemId) throw new Error("Save this widget to run its server queries");
return services.transport.query({ itemId, name, input }, signal) as Promise<T>;
},
enabled: Boolean(itemId) && context.visible && options.enabled !== false,
refetchInterval: options.refetchInterval ?? false,
staleTime: options.staleTime ?? (typeof options.refetchInterval === "number" ? options.refetchInterval : 1_000),
retry: false,
});
if (isAccessError(query.error)) return { ...query, data: undefined };
return query;
}
function isAccessError(error: unknown) {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data)) return false;
return data.code === "FORBIDDEN" || data.code === "UNAUTHORIZED" || data.code === "NOT_FOUND";
}
export function useWidgetAction<TOutput = unknown, TInput = unknown>(name: string) {
const { context, services } = useWidgetRuntime();
const queryClient = useQueryClient();
return useMutation<TOutput, Error, TInput>({
mutationFn: (input) => {
const itemId = context.itemId ?? context.previewId;
if (!itemId) throw new Error("Save this widget to run its server actions");
if (context.isEditMode || context.executionEnabled === false)
throw new Error("Widget actions are currently paused");
return services.transport.action({ itemId, name, input }) as Promise<TOutput>;
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryScope(context) }),
});
}
/** Discover controls currently available to this viewer, including exact guest-approved inputs. */
export function useWidgetCapabilities() {
const host = useWidgetHost();
const query = useWidgetQuery<{ actions: Record<string, { allowed: boolean; allowedInputs?: unknown[] }> }>(
"$capabilities",
);
return {
...query,
canRun(name: string, input?: unknown) {
if (host.isEditMode || host.executionEnabled === false || query.error) return false;
const capability = query.data?.actions[`${host.handlerPrefix ?? ""}${name}`];
if (!capability?.allowed) return false;
if (!capability.allowedInputs) return true;
let effectiveInput = input;
if (effectiveInput === undefined) effectiveInput = {};
return capability.allowedInputs.some((allowed) => hashKey([allowed]) === hashKey([effectiveInput]));
},
};
}
export function useWidgetSubscription<T = unknown>(
name: string,
input?: unknown,
options: { enabled?: boolean; maximumEvents?: number } = {},
) {
const { context, services } = useWidgetRuntime();
const itemId = context.itemId ?? context.previewId;
const inputKey = hashKey([input]);
const inputRef = useRef(input);
inputRef.current = input;
let maximumEvents = 60;
if (options.maximumEvents !== undefined && Number.isFinite(options.maximumEvents))
maximumEvents = Math.max(1, Math.floor(options.maximumEvents));
const [attempt, setAttempt] = useState(0);
const enabled = Boolean(itemId) && context.visible && options.enabled !== false;
const [state, setState] = useWidgetState<{ data?: T; events: T[]; error?: Error; connected: boolean }>(
`subscription:${hashKey([context.revisionId, context.userId, name, inputKey])}`,
{ events: [], connected: false },
);
useEffect(() => {
setState((current) => ({ ...current, error: undefined, connected: false }));
if (!enabled || !itemId) return;
let active = true;
const unsubscribe = services.transport.subscribe(
{ itemId, name, input: inputRef.current },
{
next(value) {
if (!active) return;
setState((current) => ({
data: value as T,
events: Object.is(current.data, value)
? current.events
: [...current.events, value as T].slice(-maximumEvents),
connected: true,
}));
},
error(error) {
if (!active) return;
if (isAccessError(error)) setState({ events: [], error, connected: false });
else setState((current) => ({ ...current, error, connected: false }));
},
complete() {
if (active) setState((current) => ({ ...current, connected: false }));
},
},
);
return () => {
active = false;
unsubscribe();
};
}, [
attempt,
itemId,
context.revisionId,
context.userId,
enabled,
inputKey,
maximumEvents,
name,
services.transport,
setState,
]);
return { ...state, reconnect: () => setAttempt((current) => current + 1) };
}
export function createWidgetStateStore(values: Record<string, unknown> = {}): WidgetStateStore {
const listeners = new Set<() => void>();
return {
get: (key) => (Object.hasOwn(values, key) ? values[key] : undefined),
set(key, value) {
if (Object.hasOwn(values, key) && Object.is(values[key], value)) return;
Object.defineProperty(values, key, { value, enumerable: true, configurable: true, writable: true });
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
/** Instance state survives compact/advanced view changes without persisting secrets to a browser. */
export function useWidgetState<T>(key: string, initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
const { services } = useWidgetRuntime();
const [initial] = useState(initialValue);
const getSnapshot = useCallback(() => {
const value = services.state.get(key);
if (value === undefined) return initial;
return value as T;
}, [initial, key, services.state]);
const value = useSyncExternalStore(services.state.subscribe, getSnapshot, () => initial);
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(next) => {
let nextValue = next;
if (typeof next === "function") nextValue = (next as (previous: T) => T)(getSnapshot());
services.state.set(key, nextValue);
},
[getSnapshot, key, services.state],
);
return [value, setValue];
}
/** Shared storage remains server-authorized; previews use an isolated temporary store. */
export function useWidgetStorage<T = unknown>(scope: WidgetStorageScope, key: string, initialValue: T) {
const { context, services } = useWidgetRuntime();
const itemId = context.itemId ?? context.previewId;
const queryClient = useQueryClient();
const queryKey = [...queryScope(context), "storage", context.scope, scope, key];
const query = useQuery({
queryKey,
queryFn: async ({ signal }) => {
if (!itemId) return initialValue;
const value = await services.transport.storageGet({ itemId, scope, key }, signal);
if (value === undefined || value === null) return initialValue;
return value as T;
},
enabled: Boolean(itemId) && context.executionEnabled !== false,
retry: false,
});
const mutation = useMutation({
mutationFn: (value: T) => {
if (!itemId || context.isEditMode || context.executionEnabled === false) {
throw new Error("Managed widget storage cannot be changed before saving or in board edit mode");
}
return services.transport.storageSet({ itemId, scope, key, value });
},
onSuccess: (_result, value) => queryClient.setQueryData(queryKey, value),
});
return {
...query,
data: isAccessError(query.error) ? initialValue : (query.data ?? initialValue),
set: mutation.mutateAsync,
saving: mutation.isPending,
saveError: mutation.error,
};
}
export function useWidgetCommands(commands: readonly WidgetCommand[]) {
const { services } = useWidgetRuntime();
const registerCommands = services.registerCommands;
const commandsRef = useRef(commands);
commandsRef.current = commands;
const descriptorKey = hashKey(commands.map(({ run: _run, ...command }) => command));
useEffect(
() =>
registerCommands(
commandsRef.current.map((command) => ({
...command,
run: () => commandsRef.current.find(({ id }) => id === command.id)?.run(),
})),
),
[descriptorKey, registerCommands],
);
}
/** Shared native/custom clock, aligned to real time boundaries to avoid accumulating drift. */
export function useWidgetNow(intervalMs = 1000, enabled = true) {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
if (!enabled) return;
let interval = 1000;
if (Number.isFinite(intervalMs)) interval = Math.max(16, intervalMs);
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
setNow(Date.now());
timer = setTimeout(tick, interval - (Date.now() % interval));
};
setNow(Date.now());
timer = setTimeout(tick, interval - (Date.now() % interval));
return () => clearTimeout(timer);
}, [enabled, intervalMs]);
return now;
}
export function useWidgetClock(intervalMs = 1000) {
const { visible } = useWidgetHost();
return useWidgetNow(intervalMs, visible);
}
+29
View File
@@ -0,0 +1,29 @@
import sqlite from "../examples/sqlite.json";
import mqtt from "../examples/mqtt.json";
import clock from "../examples/clock.json";
import beszel from "../examples/beszel.json";
import timer from "../examples/timer.json";
import checklist from "../examples/checklist.json";
import downloads from "../examples/downloads.json";
import sonarr from "../examples/sonarr.json";
import rack from "../examples/rack.json";
import backup from "../examples/backup.json";
import household from "../examples/household.json";
import overview from "../examples/overview.json";
import media from "../examples/media.json";
export const widgetReferencePackages = {
sqlite,
mqtt,
clock,
beszel,
timer,
checklist,
downloads,
sonarr,
rack,
backup,
household,
overview,
media,
};
+5
View File
@@ -0,0 +1,5 @@
import type { ComponentType } from "react";
/** Compatibility exports are supplied by Homarr's host; new widgets should use the regular SDK. */
export declare const LegacyWidget: ComponentType<{ template: string; requestCapabilities?: unknown }>;
export declare const LegacyConfiguration: ComponentType<{ schema: unknown; parameterNames?: Record<string, string[]> }>;
+146
View File
@@ -0,0 +1,146 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface StoredWidgetState {
version: number;
value: unknown;
}
interface UseLocalWidgetStateOptions<T> {
key: string | undefined;
version: number;
defaultValue: T | (() => T);
validate?: (value: unknown) => value is T;
migrate?: (value: unknown, storedVersion: number) => T | undefined;
}
const resolveDefaultValue = <T>(value: T | (() => T)): T =>
typeof value === "function" ? (value as () => T)() : value;
export const serializeLocalWidgetState = <T>(version: number, value: T): string => JSON.stringify({ version, value });
export const parseLocalWidgetState = <T>(
serialized: string,
version: number,
validate?: (value: unknown) => value is T,
migrate?: (value: unknown, storedVersion: number) => T | undefined,
): T | undefined => {
try {
const parsed: unknown = JSON.parse(serialized);
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) {
return undefined;
}
const stored = parsed as StoredWidgetState;
if (typeof stored.version !== "number") return undefined;
if (stored.version !== version) {
const migrated = migrate?.(stored.value, stored.version);
if (migrated === undefined || (validate && !validate(migrated))) return undefined;
return migrated;
}
if (validate && !validate(stored.value)) return undefined;
return stored.value as T;
} catch {
return undefined;
}
};
export const useLocalWidgetState = <T>({
key,
version,
defaultValue,
validate,
migrate,
}: UseLocalWidgetStateOptions<T>): [T, Dispatch<SetStateAction<T>>, () => void] => {
const defaultValueRef = useRef(defaultValue);
const validateRef = useRef(validate);
const migrateRef = useRef(migrate);
defaultValueRef.current = defaultValue;
validateRef.current = validate;
migrateRef.current = migrate;
const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []);
const [value, setValueState] = useState<T>(createDefaultValue);
const valueRef = useRef(value);
const setMemoryValue = useCallback((nextValue: T) => {
valueRef.current = nextValue;
setValueState(nextValue);
}, []);
const removeStoredValue = useCallback(() => {
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}, [key]);
useEffect(() => {
if (!key) {
setMemoryValue(createDefaultValue());
return;
}
try {
const serialized = window.localStorage.getItem(key);
if (serialized === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current);
if (parsed === undefined) {
window.localStorage.removeItem(key);
setMemoryValue(createDefaultValue());
return;
}
setMemoryValue(parsed);
} catch {
setMemoryValue(createDefaultValue());
}
}, [createDefaultValue, key, setMemoryValue, version]);
useEffect(() => {
if (!key) return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== key) return;
if (event.newValue === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current);
if (parsed !== undefined) setMemoryValue(parsed);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [createDefaultValue, key, setMemoryValue, version]);
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(nextValue) => {
const resolved =
typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue;
if (Object.is(resolved, valueRef.current)) return;
setMemoryValue(resolved);
if (key) {
try {
window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved));
} catch {
// Keep the in-memory state usable when persistence is unavailable.
}
}
},
[key, setMemoryValue, version],
);
const resetValue = useCallback(() => {
removeStoredValue();
setMemoryValue(createDefaultValue());
}, [createDefaultValue, removeStoredValue, setMemoryValue]);
return [value, setValue, resetValue];
};
+173
View File
@@ -0,0 +1,173 @@
export type { WidgetJson, WidgetStorageScope } from "./shared";
export interface WidgetServerContext {
signal: AbortSignal;
instanceId: string;
boardId: string;
userId?: string;
sdk: {
invoke(operation: string, input: unknown): Promise<unknown>;
};
}
export type WidgetServerHandler<TInput = unknown, TOutput = unknown> = (
input: TInput,
context: WidgetServerContext,
) => TOutput | Promise<TOutput> | AsyncIterable<TOutput>;
/** The runtime invokes only manifest-declared handlers after authorizing the actor. */
export function defineWidgetServer<T extends Record<string, WidgetServerHandler<never>>>(handlers: T): T {
return handlers;
}
export interface WidgetIntegrationInput {
connection: string;
capability: string;
input?: unknown;
}
export interface WidgetConnectionRequest {
connection: string;
path?: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
query?: Record<string, string | number | boolean>;
headers?: Record<string, string>;
body?: unknown;
responseType?: "json" | "text" | "base64";
}
export interface WidgetConnectionResponse<T> {
data: T;
ok: boolean;
status: number;
contentType: string | null;
durationMs: number;
updatedAt: string;
}
/** Credentials, local DNS, timeout and TLS policy come from the named owner-bound connection. */
export function fetchWidgetConnection<T = unknown>(context: WidgetServerContext, input: WidgetConnectionRequest) {
return context.sdk.invoke("connection.fetch", input) as Promise<WidgetConnectionResponse<T>>;
}
export interface WidgetBoundConnection<TSettings = Record<string, unknown>> {
configuration: {
kind: "http" | "integration" | "service";
serviceType?: string;
settings?: TSettings;
baseUrl?: string;
browserUrl?: string;
timeoutMs: number;
tls?: { rejectUnauthorized: boolean; ca?: string; cert?: string };
[key: string]: unknown;
};
secrets: Record<string, string>;
}
/** Server handlers only. Use local connection settings and credentials with a Node library or native protocol. */
export async function getWidgetConnection<TSettings = Record<string, unknown>>(
context: WidgetServerContext,
input: { connection: string; serviceType?: string },
): Promise<WidgetBoundConnection<TSettings>> {
const connection = (await context.sdk.invoke("connection.get", {
connection: input.connection,
})) as WidgetBoundConnection<TSettings>;
if (
input.serviceType &&
(connection.configuration.kind !== "service" || connection.configuration.serviceType !== input.serviceType)
)
throw new Error(`Connection '${input.connection}' requires the '${input.serviceType}' service type`);
return connection;
}
export function getWidgetServerContext<TOptions extends Record<string, unknown> = Record<string, unknown>>(
context: WidgetServerContext,
) {
return context.sdk.invoke("widget.context", {}) as Promise<{
options: TOptions;
installationId: string;
bindingNames: string[];
isPreview: boolean;
}>;
}
export function callWidgetIntegration<T = unknown>(context: WidgetServerContext, input: WidgetIntegrationInput) {
return context.sdk.invoke("integration.call", input) as Promise<T>;
}
/** Bridges native streams across the worker boundary and releases them when the widget stops observing. */
export async function* subscribeWidgetIntegration<T = unknown>(
context: WidgetServerContext,
input: WidgetIntegrationInput,
): AsyncGenerator<T> {
context.signal.throwIfAborted();
const id = await context.sdk.invoke("integration.subscribe", input);
if (typeof id !== "string") throw new Error("The integration subscription returned an invalid identifier");
try {
while (!context.signal.aborted) {
const result = (await context.sdk.invoke("integration.next", { id })) as IteratorResult<T>;
if (result.done) return;
yield result.value;
}
} finally {
// Cleanup is best effort after a worker cancellation; preserve the original stream result/error.
await context.sdk.invoke("integration.unsubscribe", { id }).catch(() => undefined);
}
}
export interface WidgetRunnerInput {
connection: string;
handler: string;
input?: unknown;
}
async function runnerRequest<T>(context: WidgetServerContext, connection: string, path: string, body: unknown) {
const response = await fetchWidgetConnection<T & { error?: string }>(context, {
connection,
path,
method: "POST",
body,
});
if (!response.ok || response.data.error)
throw new Error(response.data.error ?? `Widget runner returned HTTP ${response.status}`);
return response.data;
}
/** Run an owner-installed package handler on a named remote runner connection. */
export async function callWidgetRunner<T = unknown>(context: WidgetServerContext, input: WidgetRunnerInput) {
const result = await runnerRequest<{ value: T }>(context, input.connection, "/invoke", {
handler: input.handler,
input: input.input,
instanceId: context.instanceId,
boardId: context.boardId,
userId: context.userId,
});
return result.value;
}
export async function* subscribeWidgetRunner<T = unknown>(
context: WidgetServerContext,
input: WidgetRunnerInput,
): AsyncGenerator<T> {
const { id } = await runnerRequest<{ id: string }>(context, input.connection, "/subscriptions/start", {
handler: input.handler,
input: input.input,
instanceId: context.instanceId,
boardId: context.boardId,
userId: context.userId,
});
try {
while (!context.signal.aborted) {
const result = await runnerRequest<{ done: boolean; values: T[] }>(
context,
input.connection,
"/subscriptions/next",
{ id },
);
for (const value of result.values) yield value;
if (result.done) return;
}
} finally {
await runnerRequest(context, input.connection, "/subscriptions/cancel", { id }).catch(() => undefined);
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { ReactNode } from "react";
export type WidgetStorageScope = "user" | "instance" | "installation";
export type WidgetDisplayMode = "compact" | "advanced";
export type WidgetJson = null | boolean | number | string | WidgetJson[] | { [key: string]: WidgetJson };
export interface WidgetOperationInput {
itemId: string;
name: string;
input: unknown;
}
export interface WidgetStorageInput {
itemId: string;
scope: WidgetStorageScope;
key: string;
}
export interface WidgetSubscriptionObserver {
next(value: unknown): void;
error(error: Error): void;
complete(): void;
}
export interface WidgetTransport {
query(input: WidgetOperationInput, signal: AbortSignal): Promise<unknown>;
action(input: WidgetOperationInput): Promise<unknown>;
subscribe(input: WidgetOperationInput, observer: WidgetSubscriptionObserver): () => void;
storageGet(input: WidgetStorageInput, signal: AbortSignal): Promise<unknown>;
storageSet(input: WidgetStorageInput & { value: unknown }): Promise<unknown>;
}
export interface WidgetCommand {
id: string;
label: string;
description?: string;
disabled?: boolean;
hidden?: boolean;
destructive?: boolean;
run(): void | Promise<void>;
}
export interface WidgetHostContext {
sdkVersion: "1";
itemId?: string;
previewId?: string;
boardId?: string;
installationId?: string;
revisionId: string;
scope?: string;
handlerPrefix?: string;
displayMode: WidgetDisplayMode;
width: number;
height: number;
displayScale: number;
visibleWidth: number;
visibleHeight: number;
isEditMode: boolean;
isPreview: boolean;
visible: boolean;
/** False when an authoring preview preserves its last successful view without executing requests or controls. */
executionEnabled?: boolean;
/** Dashboard refresh setting; compatibility widgets use it for their declared load queries. */
refreshIntervalSeconds?: number;
locale: string;
timeZone: string;
colorScheme: "light" | "dark";
reducedMotion: boolean;
userId?: string;
}
export interface WidgetStateStore {
get(key: string): unknown;
set(key: string, value: unknown): void;
subscribe(listener: () => void): () => void;
}
export interface WidgetNotification {
title: string;
message?: string;
color?: string;
}
export interface WidgetHostServices {
transport: WidgetTransport;
state: WidgetStateStore;
registerCommands(commands: readonly WidgetCommand[]): () => void;
notify(notification: WidgetNotification): void;
navigate(href: string): void;
openAdvanced(): void;
closeAdvanced(): void;
openDetail(detail: { title: string; content: ReactNode }): void;
closeDetail(): void;
confirm(input: { title: string; message: string; destructive?: boolean }): Promise<boolean>;
updateOptions(options: Record<string, unknown>): Promise<void>;
}
+8
View File
@@ -0,0 +1,8 @@
export { HomarrDataTable } from "./table/homarr-data-table";
export {
parseColumnOrder,
parseColumnWidths,
usePersistedTableLayout,
useTableLayoutPersistence,
} from "./table/use-persisted-table-layout";
export type { DataTableColumn, DataTableProps, DataTableSortStatus } from "mantine-datatable";
@@ -0,0 +1,95 @@
.homarr-data-table,
.homarr-data-table .mantine-datatable-table,
.homarr-data-table .mantine-datatable-table thead,
.homarr-data-table .mantine-datatable-table tbody,
.homarr-data-table .mantine-datatable-table tfoot,
.homarr-data-table th,
.homarr-data-table td {
background-color: transparent !important;
}
.homarr-data-table .mantine-datatable-table tr {
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table {
width: 100% !important;
max-width: 100%;
table-layout: fixed !important;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-8);
}
}
.homarr-data-table th {
padding: 0;
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
.homarr-data-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Lets a cell's content (e.g. an oversized action icon) visually overflow its own box without
growing it - used to make a per-row icon look bigger without inflating row height, since the
icon is pulled out of flow via absolute positioning rather than actually being a bigger cell. */
.homarr-data-table td.docker-actions-cell {
overflow: visible;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
flex-wrap: nowrap;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
opacity: 0;
transition: opacity 150ms ease-out;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-group {
gap: 0;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
margin: 0;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-unsorted-icon {
display: none;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-text svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-icon svg {
width: var(--mantine-font-size-md);
height: var(--mantine-font-size-md);
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
opacity: 1;
}
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
cursor: default;
}
@@ -0,0 +1,2 @@
declare const stylesheet: string;
export default stylesheet;
@@ -0,0 +1,163 @@
"use client";
// Shared CSS is required by both native and package table consumers.
// eslint-disable-next-line import/no-unassigned-import
import "./homarr-data-table.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import { scrollAreaDefaultProps } from "../ui-settings";
const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width === "string") {
const parsedWidth = Number.parseFloat(width);
if (Number.isFinite(parsedWidth) && parsedWidth > 0) return parsedWidth;
}
return fallbackColumnWidth;
};
type HomarrDataTableProps<T> = DataTableProps<T> & {
columns: DataTableColumn<T>[];
groups?: never;
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
};
export function HomarrDataTable<T>({
isEditMode,
cellPadding,
rowCursor = "auto",
className,
columns,
defaultColumnProps,
onMouseDownCapture,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const [resizePreviewWidths, setResizePreviewWidths] = useState<Record<string, number>>();
const removeResizeListeners = useRef<() => void>(() => undefined);
useEffect(() => () => removeResizeListeners.current(), []);
const handleMouseDownCapture = useCallback(
(event: React.MouseEvent<HTMLTableElement>) => {
onMouseDownCapture?.(event);
const target = event.target;
if (!(target instanceof Element)) return;
const handle = target.closest(".mantine-datatable-header-resizable-handle");
const currentCell = handle?.closest<HTMLTableCellElement>("th[data-accessor]");
const headerRow = currentCell?.parentElement;
if (!currentCell || !headerRow) return;
const cells = Array.from(headerRow.querySelectorAll<HTMLTableCellElement>("th[data-accessor]"));
const currentIndex = cells.indexOf(currentCell);
const nextCell = cells[currentIndex + 1];
const currentAccessor = currentCell.dataset.accessor;
const nextAccessor = nextCell?.dataset.accessor;
if (!nextCell || !currentAccessor || !nextAccessor) return;
const widths = Object.fromEntries(
cells.flatMap((cell) => {
const accessor = cell.dataset.accessor;
if (!accessor) return [];
return [[accessor, cell.getBoundingClientRect().width]];
}),
);
const currentWidth = widths[currentAccessor];
const nextWidth = widths[nextAccessor];
if (currentWidth === undefined || nextWidth === undefined) return;
const visualScale = currentCell.getBoundingClientRect().width / currentCell.offsetWidth;
const minimumWidth = fallbackColumnWidth * visualScale;
const startX = event.clientX;
removeResizeListeners.current();
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = Math.min(
nextWidth - minimumWidth,
Math.max(minimumWidth - currentWidth, moveEvent.clientX - startX),
);
setResizePreviewWidths({
...widths,
[currentAccessor]: currentWidth + delta,
[nextAccessor]: nextWidth - delta,
});
};
const handleResizeEnd = () => {
removeResizeListeners.current();
requestAnimationFrame(() => requestAnimationFrame(() => setResizePreviewWidths(undefined)));
};
removeResizeListeners.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("blur", handleResizeEnd);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("blur", handleResizeEnd);
},
[onMouseDownCapture],
);
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
className,
]
.filter(Boolean)
.join(" ");
const fittedColumns = useMemo(() => {
const visibleColumns = columns.filter(({ hidden }) => !hidden);
const getRenderedWidth = (column: DataTableColumn<T>) =>
resizePreviewWidths?.[String(column.accessor)] ?? getColumnWidth(column.width);
const totalWidth = visibleColumns.reduce((total, column) => total + getRenderedWidth(column), 0);
if (totalWidth <= 0) return columns;
return columns.map((column) => {
if (column.hidden) return column;
const width = `${(getRenderedWidth(column) / totalWidth) * 100}%`;
const fittedWidth = { width, minWidth: width, maxWidth: width };
const cellsStyle = column.cellsStyle ?? defaultColumnProps?.cellsStyle;
return {
...column,
titleStyle: [column.titleStyle, fittedWidth],
cellsStyle: (record: T, index: number) => [cellsStyle?.(record, index), fittedWidth, { padding: cellPadding }],
};
});
}, [cellPadding, columns, defaultColumnProps?.cellsStyle, resizePreviewWidths]);
return (
<DataTable<T>
withTableBorder={false}
borderRadius={0}
highlightOnHover
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
loaderBackgroundBlur={2}
textSelectionDisabled
height="100%"
{...props}
striped={false}
columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
...defaultColumnProps,
noWrap: defaultColumnProps?.noWrap ?? true,
draggable: true,
resizable: true,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
/>
);
}
@@ -0,0 +1,235 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { DataTableColumn } from "mantine-datatable";
import { useDataTableColumns } from "mantine-datatable";
import { isRecord } from "@homarr/common";
interface TableLayoutOptions extends Record<string, unknown> {
columnOrder?: string;
columnWidths?: string;
}
interface PendingTableLayout {
layout: TableLayoutOptions;
onLayoutChange: (layout: TableLayoutOptions) => void;
storeKey: string;
}
interface UseTableLayoutPersistenceProps {
boardId: string | undefined;
hasChangeAccess: boolean;
itemId: string | undefined;
saveItemOptions: (input: { boardId: string; itemId: string; newOptions: TableLayoutOptions }) => void;
setOptions: (input: { newOptions: TableLayoutOptions }) => void;
}
interface UsePersistedTableLayoutProps<T> {
columns: DataTableColumn<T>[];
columnAccessors: readonly string[];
columnOrder: string;
columnWidths: string;
itemId: string | undefined;
storeKeyPrefix: string;
onLayoutChange: (layout: TableLayoutOptions) => void;
}
const layoutSaveDelay = 350;
export const useTableLayoutPersistence = ({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
}: UseTableLayoutPersistenceProps) =>
useCallback(
(newOptions: TableLayoutOptions) => {
setOptions({ newOptions });
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
},
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
);
const toPixelWidth = (width: string | number): number | undefined => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width !== "string" || !width.endsWith("px")) return undefined;
const parsedWidth = Number.parseInt(width, 10);
return Number.isFinite(parsedWidth) && parsedWidth > 0 ? parsedWidth : undefined;
};
const getColumnWidthMap = (
columnsWidth: Record<string, string | number>[],
visibleAccessorSet: ReadonlySet<string>,
): Record<string, number> => {
const widths: Record<string, number> = {};
for (const entry of columnsWidth) {
for (const [accessor, width] of Object.entries(entry)) {
if (!visibleAccessorSet.has(accessor)) continue;
const pixelWidth = toPixelWidth(width);
if (pixelWidth !== undefined) widths[accessor] = pixelWidth;
}
}
return widths;
};
export const parseColumnOrder = (value: string, columnAccessors: readonly string[]): string[] => {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
if (!Array.isArray(parsed)) return [];
const allowed = new Set(columnAccessors);
return parsed.filter(
(accessor, index): accessor is string =>
typeof accessor === "string" && allowed.has(accessor) && parsed.indexOf(accessor) === index,
);
} catch {
return [];
}
};
export const parseColumnWidths = (value: string, columnAccessors: readonly string[]): Record<string, number> => {
if (!value) return {};
try {
const parsed: unknown = JSON.parse(value);
if (!isRecord(parsed)) return {};
const allowed = new Set(columnAccessors);
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, number] =>
allowed.has(entry[0]) && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] > 0,
),
);
} catch {
return {};
}
};
export const usePersistedTableLayout = <T>({
columns,
columnAccessors,
columnOrder,
columnWidths,
itemId,
storeKeyPrefix,
onLayoutChange,
}: UsePersistedTableLayoutProps<T>) => {
const savedOrder = useMemo(() => parseColumnOrder(columnOrder, columnAccessors), [columnAccessors, columnOrder]);
const savedWidths = useMemo(() => parseColumnWidths(columnWidths, columnAccessors), [columnAccessors, columnWidths]);
const visibleAccessors = useMemo(() => columns.map(({ accessor }) => String(accessor)), [columns]);
const visibleAccessorSet = useMemo(() => new Set(visibleAccessors), [visibleAccessors]);
const restoredOrder = useMemo(
() => [
...savedOrder.filter((accessor) => visibleAccessorSet.has(accessor)),
...visibleAccessors.filter((accessor) => !savedOrder.includes(accessor)),
],
[savedOrder, visibleAccessors, visibleAccessorSet],
);
const storeKey = `${storeKeyPrefix}-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`;
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<T>({
key: storeKey,
columns,
});
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
const previousOrder = useRef(columnsOrder);
const previousWidths = useRef(columnsWidth);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
const orderMatches =
columnsOrder.length === restoredOrder.length &&
columnsOrder.every((accessor, index) => accessor === restoredOrder[index]);
const currentWidths = getColumnWidthMap(columnsWidth, visibleAccessorSet);
const visibleWidths = Object.entries(savedWidths).filter(([accessor]) => visibleAccessorSet.has(accessor));
const widthsMatch = visibleWidths.every(([accessor, width]) => currentWidths[accessor] === width);
if (orderMatches && widthsMatch) {
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
hydrated.current = true;
return;
}
if (!orderMatches) setColumnsOrder(restoredOrder);
if (!widthsMatch) {
setMultipleColumnWidths(visibleWidths.map(([accessor, width]) => ({ accessor, width })));
}
}, [
columnsOrder,
columnsWidth,
restoredOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
storeKey,
visibleAccessorSet,
]);
const pendingLayout = useRef<PendingTableLayout | undefined>(undefined);
const saveTimeout = useRef<number | undefined>(undefined);
const flushPendingLayout = useCallback(() => {
const pending = pendingLayout.current;
pendingLayout.current = undefined;
window.clearTimeout(saveTimeout.current);
saveTimeout.current = undefined;
if (pending) pending.onLayoutChange(pending.layout);
}, []);
useEffect(() => {
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
}, [flushPendingLayout, storeKey]);
useEffect(() => () => flushPendingLayout(), [flushPendingLayout]);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(previousOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(previousWidths.current);
previousOrder.current = columnsOrder;
previousWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
const newLayout: TableLayoutOptions = {};
if (orderChanged) {
const fullOrder = [...savedOrder, ...columnAccessors.filter((accessor) => !savedOrder.includes(accessor))];
const reorderedVisibleColumns = [...columnsOrder];
const mergedOrder = fullOrder.map((accessor) =>
visibleAccessorSet.has(accessor) ? (reorderedVisibleColumns.shift() ?? accessor) : accessor,
);
newLayout.columnOrder = JSON.stringify(mergedOrder);
}
if (widthsChanged) {
const widthMap: Record<string, number> = { ...savedWidths };
Object.assign(widthMap, getColumnWidthMap(columnsWidth, visibleAccessorSet));
newLayout.columnWidths = JSON.stringify(widthMap);
}
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
pendingLayout.current = pendingLayout.current
? { ...pendingLayout.current, layout: { ...pendingLayout.current.layout, ...newLayout } }
: { layout: newLayout, onLayoutChange, storeKey };
window.clearTimeout(saveTimeout.current);
saveTimeout.current = window.setTimeout(flushPendingLayout, layoutSaveDelay);
}, [
columnAccessors,
columnsOrder,
columnsWidth,
flushPendingLayout,
onLayoutChange,
savedOrder,
savedWidths,
storeKey,
visibleAccessorSet,
]);
return { effectiveColumns, storeKey };
};
@@ -0,0 +1,27 @@
"use client";
import { useParams } from "next/navigation";
import { useSuspenseQuery } from "@tanstack/react-query";
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
import { useMantineReactTable } from "mantine-react-table";
import type { SupportedLanguage } from "@homarr/translation";
import { localeConfigurations } from "@homarr/translation";
export const useTranslatedMantineReactTable = <TData extends MRT_RowData>(
tableOptions: Omit<MRT_TableOptions<TData>, "localization">,
) => {
const { locale } = useParams<{ locale: SupportedLanguage }>();
const { data: mantineReactTable } = useSuspenseQuery({
queryKey: ["mantine-react-table-locale", locale],
// eslint-disable-next-line no-restricted-syntax
queryFn: async () => {
return await localeConfigurations[locale].importMrtLocalization();
},
});
return useMantineReactTable<TData>({
...tableOptions,
localization: mantineReactTable,
});
};
+6
View File
@@ -0,0 +1,6 @@
export const scrollAreaDefaultProps = {
type: "hover",
scrollbarSize: 2,
scrollHideDelay: 500,
offsetScrollbars: true,
} as const;
+42
View File
@@ -0,0 +1,42 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Alert, Center, Loader, Stack, Text } from "@mantine/core";
export { ActionIcon, Badge, Box, Button, Card, Group, SimpleGrid, Stack, Table, Text, Title } from "@mantine/core";
export function WidgetEmptyState({ children }: PropsWithChildren) {
return (
<Center h="100%">
<Text c="dimmed" size="sm" ta="center">
{children}
</Text>
</Center>
);
}
export function WidgetLoading({ label }: { label?: string }) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<Loader size="sm" />
{label && <Text size="sm">{label}</Text>}
</Stack>
</Center>
);
}
export function WidgetError({ children, title, action }: PropsWithChildren<{ title?: string; action?: ReactNode }>) {
return (
<Alert color="red" title={title}>
{children}
{action}
</Alert>
);
}
export { HomarrDataTable, usePersistedTableLayout, useTableLayoutPersistence } from "./table";
export type { DataTableColumn, DataTableProps, DataTableSortStatus } from "./table";
export { MantineReactTable } from "mantine-react-table";
export type { MRT_ColumnDef, MRT_TableOptions } from "mantine-react-table";
export { useTranslatedMantineReactTable } from "./table/use-translated-mantine-react-table";