feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -9,6 +9,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
initialOptions: Record<string, unknown>;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
||||
|
||||
@@ -17,7 +17,13 @@ export interface DynamicSelectOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => {
|
||||
export const WidgetDynamicSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
itemId,
|
||||
boardId,
|
||||
}: CommonWidgetInputProps<"dynamicSelect">) => {
|
||||
const t = useI18n();
|
||||
const tWidget = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
@@ -28,6 +34,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
|
||||
debouncedSearch,
|
||||
form.values.integrationIds,
|
||||
form.values.options,
|
||||
itemId,
|
||||
boardId,
|
||||
);
|
||||
const currentOption = inputProps.value as DynamicSelectOption | null;
|
||||
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Quill } from "react-quill-new";
|
||||
import type { DeltaStatic } from "react-quill-new";
|
||||
import type ReactQuillComponent from "react-quill-new";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { AnchorNotePermission } from "@homarr/integrations";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -106,6 +107,13 @@ const stringifyDelta = (delta: unknown): string => {
|
||||
return JSON.stringify(emptyDelta());
|
||||
};
|
||||
|
||||
export const storedContentToPlainText = (content?: string | null): string => {
|
||||
return parseStoredContent(content)
|
||||
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const canEditPermission = (permission: AnchorNotePermission) => {
|
||||
return permission === "owner" || permission === "editor";
|
||||
};
|
||||
@@ -119,7 +127,14 @@ const isForbiddenError = (error: unknown): boolean => {
|
||||
return (data as { code?: unknown }).code === "FORBIDDEN";
|
||||
};
|
||||
|
||||
export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
|
||||
export default function AnchorNoteWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"anchorNote">) {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const noteId = options.noteId.trim();
|
||||
if (!noteId) {
|
||||
@@ -134,27 +149,74 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const integrationId = integrationIds[0]!;
|
||||
|
||||
return <AnchorNoteWidgetContent options={options} integrationId={integrationId} noteId={noteId} />;
|
||||
return (
|
||||
<AnchorNoteWidgetContent
|
||||
options={options}
|
||||
integrationId={integrationId}
|
||||
noteId={noteId}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnchorNotePersistedState {
|
||||
integrationId: string;
|
||||
noteId: string;
|
||||
isEditing: boolean;
|
||||
draftTitle: string;
|
||||
draftContent: string;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
interface AnchorNoteWidgetContentProps {
|
||||
options: WidgetComponentProps<"anchorNote">["options"];
|
||||
integrationId: string;
|
||||
noteId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"];
|
||||
}
|
||||
|
||||
const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
|
||||
const AnchorNoteWidgetContent = ({
|
||||
options,
|
||||
integrationId,
|
||||
noteId,
|
||||
width,
|
||||
height,
|
||||
isAdvanced,
|
||||
widgetStateRef,
|
||||
}: AnchorNoteWidgetContentProps) => {
|
||||
const t = useScopedI18n("widget.anchorNote");
|
||||
const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined;
|
||||
const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId;
|
||||
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
|
||||
integrationId,
|
||||
noteId,
|
||||
});
|
||||
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
|
||||
const [draftContent, setDraftContent] = useState(note?.content ?? "");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
|
||||
const [draftTitle, setDraftTitle] = useState(
|
||||
canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""),
|
||||
);
|
||||
const [draftContent, setDraftContent] = useState(
|
||||
canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""),
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(
|
||||
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError },
|
||||
};
|
||||
}, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing || !note) return;
|
||||
@@ -165,7 +227,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
});
|
||||
}, [isEditing, note]);
|
||||
|
||||
const canEdit = canEditPermission(note?.permission ?? "viewer");
|
||||
const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
|
||||
const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess;
|
||||
const isViewer = !note || note.permission === "viewer";
|
||||
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
|
||||
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
|
||||
@@ -178,6 +241,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
|
||||
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
|
||||
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
|
||||
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
|
||||
const handleEditorChange = useCallback<ReactQuillOnChange>(
|
||||
(_html, _delta, source, editor) => {
|
||||
if (!isEditing || source !== "user") return;
|
||||
@@ -260,7 +324,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
if (!note) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="xs" p="sm">
|
||||
<Stack h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
{isEditing ? (
|
||||
@@ -278,6 +342,22 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
{t("readOnlyViewer")}
|
||||
</Text>
|
||||
)}
|
||||
{!isEditing && isAdvanced && (
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light">
|
||||
{t(`permission.${note.permission}`)}
|
||||
</Badge>
|
||||
{note.isPinned && <Badge size="xs">{t("status.pinned")}</Badge>}
|
||||
{note.isArchived && (
|
||||
<Badge size="xs" color="gray">
|
||||
{t("status.archived")}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{saveError && (
|
||||
<Text size="xs" c="red">
|
||||
{saveError}
|
||||
@@ -301,17 +381,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<div className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`} style={{ flex: 1 }}>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={!isEditing}
|
||||
value={isEditing ? editorValue : readOnlyValue}
|
||||
onChange={handleEditorChange}
|
||||
modules={isEditing ? quillModules : readOnlyModules}
|
||||
formats={quillFormats}
|
||||
placeholder={t("emptyContent")}
|
||||
/>
|
||||
</div>
|
||||
{isEditing || isAdvanced ? (
|
||||
<div
|
||||
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
readOnly={!isEditing}
|
||||
value={isEditing ? editorValue : readOnlyValue}
|
||||
onChange={handleEditorChange}
|
||||
modules={isEditing ? quillModules : readOnlyModules}
|
||||
formats={quillFormats}
|
||||
placeholder={t("emptyContent")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Text
|
||||
size={width < 180 || height < 100 ? "xs" : "sm"}
|
||||
c={plainText ? undefined : "dimmed"}
|
||||
style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}
|
||||
lineClamp={Math.max(2, Math.floor((height - 70) / 18))}
|
||||
>
|
||||
{plainText || t("emptyContent")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,9 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
|
||||
icon: IconNotes,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "anchorNotes"]],
|
||||
maxIntegrations: 1,
|
||||
supportedIntegrations: ["anchor"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -19,7 +19,7 @@ import classes from "./app.module.css";
|
||||
import { PingDot } from "./ping/ping-dot";
|
||||
import { PingIndicator } from "./ping/ping-indicator";
|
||||
|
||||
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
|
||||
export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
@@ -51,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
const isTiny = height < 100 || width < 100;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isColumnLayout = options.layout.startsWith("column");
|
||||
|
||||
return (
|
||||
@@ -83,7 +84,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{options.showTitle && (
|
||||
{(options.showTitle || isAdvanced) && (
|
||||
<Text
|
||||
className="app-title"
|
||||
fw={700}
|
||||
@@ -93,13 +94,13 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
{app.name}
|
||||
</Text>
|
||||
)}
|
||||
{options.descriptionDisplayMode === "normal" && (
|
||||
{(options.descriptionDisplayMode === "normal" || isAdvanced) && (
|
||||
<Text
|
||||
className="app-description"
|
||||
size={isTiny ? rem(8) : "sm"}
|
||||
ta={isColumnLayout ? "center" : undefined}
|
||||
c="dimmed"
|
||||
lineClamp={4}
|
||||
lineClamp={isAdvanced ? 10 : Math.max(1, Math.floor((height - 48) / 18))}
|
||||
>
|
||||
{app.description?.split("\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -109,6 +110,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && app.href && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{getDisplayHost(app.href)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
@@ -119,7 +125,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
minWidth: "20%",
|
||||
maxWidth: isColumnLayout ? undefined : "50%",
|
||||
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
@@ -154,3 +160,11 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
const getDisplayHost = (href: string) => {
|
||||
try {
|
||||
return new URL(href).host;
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function ArchiveTeamWarriorWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"archiveTeamWarrior">) {
|
||||
const integrationId = integrationIds[0];
|
||||
|
||||
@@ -18,23 +22,39 @@ export default function ArchiveTeamWarriorWidget({
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
|
||||
return (
|
||||
<ArchiveTeamWarriorWidgetContent
|
||||
integrationId={integrationId}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ArchiveTeamWarriorWidgetContent = ({
|
||||
integrationId,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
isAdvanced,
|
||||
}: {
|
||||
integrationId: string;
|
||||
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.archiveTeamWarrior");
|
||||
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
|
||||
|
||||
const status = data.status;
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
|
||||
const statusKey = getStatusKey(status.status);
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
<Stack p="xs" gap="xs" h="100%" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
|
||||
@@ -43,12 +63,12 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
|
||||
{status.status}
|
||||
<Badge size="sm" color={getStatusColor(statusKey)}>
|
||||
{t(`status.${statusKey}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{options.showBroadcastMessage && status.broadcastMessage && (
|
||||
{options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" lineClamp={3}>
|
||||
{status.broadcastMessage}
|
||||
@@ -56,19 +76,52 @@ const ArchiveTeamWarriorWidgetContent = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label="Running" value={status.counts.running} />
|
||||
<Metric label="Done" value={status.counts.completed} />
|
||||
<Metric label="Failed" value={status.counts.failed} />
|
||||
<Metric label="Canceled" value={status.counts.canceled} />
|
||||
<SimpleGrid cols={width < 180 || height < 120 ? 4 : 2} spacing="xs">
|
||||
<Metric label={t("metric.running")} value={status.counts.running} />
|
||||
<Metric label={t("metric.completed")} value={status.counts.completed} />
|
||||
<Metric label={t("metric.failed")} value={status.counts.failed} />
|
||||
<Metric label={t("metric.canceled")} value={status.counts.canceled} />
|
||||
</SimpleGrid>
|
||||
|
||||
{status.bandwidth && (
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
|
||||
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
|
||||
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} />
|
||||
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={4}>
|
||||
{status.items.map((item) => (
|
||||
<Card key={item.id} withBorder p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.project ?? projectName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge size="xs" color={getStatusColor(item.status)}>
|
||||
{t(`status.${item.status}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -85,3 +138,33 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
|
||||
);
|
||||
|
||||
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
|
||||
|
||||
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
|
||||
|
||||
const getStatusKey = (status?: string | null): WarriorStatusKey => {
|
||||
switch (status?.trim().toLowerCase()) {
|
||||
case "running":
|
||||
return "running";
|
||||
case "done":
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "canceled":
|
||||
case "cancelled":
|
||||
return "canceled";
|
||||
case "stopped":
|
||||
return "stopped";
|
||||
case "idle":
|
||||
return "idle";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: WarriorStatusKey): string => {
|
||||
if (status === "running") return "green";
|
||||
if (status === "failed") return "red";
|
||||
if (status === "canceled" || status === "stopped") return "yellow";
|
||||
return "gray";
|
||||
};
|
||||
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
|
||||
icon: IconArchive,
|
||||
queryKey: [["widget", "archiveTeamWarrior"]],
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showBroadcastMessage: factory.switch({
|
||||
|
||||
@@ -1,17 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatBytes, formatDuration } from "@homarr/common";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
|
||||
export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
|
||||
export default function AudioStatsWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"audioStats">) {
|
||||
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
|
||||
{ integrationIds, showOnlyPlaying: false },
|
||||
{ enabled: displayMode === "advanced" && response?.kind === "navidrome" },
|
||||
);
|
||||
|
||||
if (!response) return <WidgetEmptyState />;
|
||||
|
||||
return <AudioStatsContent backend={response.kind} stats={response.data} options={options} width={width} />;
|
||||
const summary = (
|
||||
<AudioStatsContent
|
||||
backend={response.kind}
|
||||
stats={response.data}
|
||||
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
if (displayMode === "compact") return summary;
|
||||
|
||||
const sessions = streamResults.flatMap((result) =>
|
||||
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
|
||||
);
|
||||
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
|
||||
return (
|
||||
<Stack h="100%" gap="lg" p="md">
|
||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
{response.kind === "navidrome" ? (
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="xs">
|
||||
{sessions.map(({ integrationId, session }) => {
|
||||
const currentlyPlaying = session.currentlyPlaying;
|
||||
if (!currentlyPlaying) return null;
|
||||
return (
|
||||
<Group key={`${integrationId}:${session.sessionId}`} justify="space-between" wrap="nowrap" p="sm">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{currentlyPlaying.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light">{session.sessionName}</Badge>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="xs">
|
||||
{(audiobookStats?.libraries ?? []).map((library) => (
|
||||
<Stack key={library.id} gap={2} p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{library.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{library.totalItems}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "}
|
||||
{library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
|
||||
icon: IconHeadphones,
|
||||
queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
|
||||
supportedIntegrations: ["navidrome", "audiobookshelf"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
|
||||
@@ -36,7 +36,13 @@ const iconSizeByWidth = [
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
|
||||
export default function BazarrWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"bazarr">) {
|
||||
const t = useScopedI18n("widget.bazarr");
|
||||
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
@@ -53,11 +59,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
|
||||
} as const;
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const gridCols = getGridCols(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
|
||||
const iconSize = getIconSize(Math.min(width, height));
|
||||
|
||||
if (visibleStatKeys.length === 0) {
|
||||
return (
|
||||
@@ -92,12 +98,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
|
||||
);
|
||||
}
|
||||
|
||||
function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
|
||||
if (itemCount <= 1) return 1;
|
||||
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
|
||||
|
||||
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
|
||||
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
|
||||
const rowHeight = height / rowsAtWidth;
|
||||
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
|
||||
}
|
||||
|
||||
function getIconSize(width: number): number {
|
||||
export function getIconSize(width: number): number {
|
||||
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 16;
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
icon: IconBadgeCc,
|
||||
queryKey: [["widget", "bazarr"]],
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissingEpisodes: factory.switch({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridCols, getIconSize } from "./component";
|
||||
|
||||
describe("Bazarr responsive layout", () => {
|
||||
test("uses two columns for a short widget so all stats remain visible", () => {
|
||||
expect(getGridCols(220, 100, 4)).toBe(2);
|
||||
});
|
||||
|
||||
test("uses four columns in a wide advanced surface", () => {
|
||||
expect(getGridCols(800, 400, 4, true)).toBe(4);
|
||||
});
|
||||
|
||||
test("sizes icons from the constrained dimension", () => {
|
||||
expect(getIconSize(180)).toBe(16);
|
||||
expect(getIconSize(340)).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,8 @@ export default function BeszelAlertsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"beszelAlerts">) {
|
||||
const t = useScopedI18n("widget.beszelAlerts");
|
||||
const alertsInput = useMemo(
|
||||
@@ -67,6 +69,8 @@ export default function BeszelAlertsWidget({
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered);
|
||||
const okAlerts = alerts.filter((a) => !a.triggered);
|
||||
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
|
||||
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
@@ -117,9 +121,9 @@ export default function BeszelAlertsWidget({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
{triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
{showOkAlerts && okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
@@ -140,7 +144,7 @@ export default function BeszelAlertsWidget({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
{showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={6}>
|
||||
|
||||
@@ -330,7 +330,9 @@ const metricRenderers = [
|
||||
] as const;
|
||||
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
|
||||
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||
const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
|
||||
const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
|
||||
const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -362,6 +364,11 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
|
||||
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
|
||||
{visibleMetrics.map((m) => m.render(system, t, size))}
|
||||
</Stack>
|
||||
{hiddenMetricCount > 0 && (
|
||||
<Text size="10px" c="dimmed" ta="right">
|
||||
+{hiddenMetricCount}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -372,6 +379,7 @@ export default function BeszelSystemGridWidget({
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const board = useRequiredBoard();
|
||||
@@ -411,14 +419,18 @@ export default function BeszelSystemGridWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const cols = getColCount(width, height, filteredSystems.length);
|
||||
const advanced = displayMode === "advanced";
|
||||
const cols = advanced
|
||||
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
|
||||
: getColCount(width, height, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
|
||||
const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
|
||||
const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
|
||||
const scrollEnabled = rawCellHeight < minimumCellHeight;
|
||||
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
|
||||
const cellWidth = width / cols;
|
||||
const size = getSizeConfig(cellWidth, effectiveCellHeight);
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
@@ -428,7 +440,7 @@ export default function BeszelSystemGridWidget({
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
|
||||
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -16,12 +16,15 @@ import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
export default function BeszelSystemStatsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
@@ -37,13 +40,11 @@ export default function BeszelSystemStatsWidget({
|
||||
error: systemsError,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
|
||||
const systems = useMemo(
|
||||
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
|
||||
[systemsResult],
|
||||
);
|
||||
const selectedSystem = options.systemId || systems[0]?.value || "";
|
||||
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
|
||||
const systemExists = systems.some((system) => system.value === selectedSystem);
|
||||
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
|
||||
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
|
||||
const selectedValue = selectedSystem?.value ?? "";
|
||||
const selectedLabel = selectedSystem?.label;
|
||||
const systemExists = selectedSystem !== undefined;
|
||||
|
||||
const handleSelectSystem = useCallback(
|
||||
(value: string) => {
|
||||
@@ -55,6 +56,16 @@ export default function BeszelSystemStatsWidget({
|
||||
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
|
||||
);
|
||||
|
||||
const handleTimePeriod = useCallback(
|
||||
(value: string) => {
|
||||
setOptions({ newOptions: { timePeriod: value as BeszelTimePeriod } });
|
||||
if (hasChangeAccess && boardId && itemId) {
|
||||
saveItemOptions({ boardId, itemId, newOptions: { timePeriod: value } });
|
||||
}
|
||||
},
|
||||
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
if (systemsPending)
|
||||
return (
|
||||
@@ -90,7 +101,7 @@ export default function BeszelSystemStatsWidget({
|
||||
<Select
|
||||
size="xs"
|
||||
data={systems}
|
||||
value={selectedSystem}
|
||||
value={selectedValue}
|
||||
placeholder={t("selectSystem")}
|
||||
onChange={(value) => value && handleSelectSystem(value)}
|
||||
/>
|
||||
@@ -127,8 +138,8 @@ export default function BeszelSystemStatsWidget({
|
||||
<Menu.Item
|
||||
key={system.value}
|
||||
fz="xs"
|
||||
fw={system.value === selectedSystem ? 600 : 400}
|
||||
c={system.value !== selectedSystem ? "dimmed" : undefined}
|
||||
fw={system.value === selectedValue ? 600 : 400}
|
||||
c={system.value !== selectedValue ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(system.value)}
|
||||
>
|
||||
{system.label}
|
||||
@@ -137,20 +148,35 @@ export default function BeszelSystemStatsWidget({
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{!isEditMode && displayMode === "advanced" && (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={options.timePeriod}
|
||||
onChange={handleTimePeriod}
|
||||
data={[
|
||||
{ value: "1m", label: t("period.live") },
|
||||
{ value: "1h", label: t("period.oneHour") },
|
||||
{ value: "12h", label: t("period.twelveHours") },
|
||||
{ value: "24h", label: t("period.oneDay") },
|
||||
{ value: "1w", label: t("period.oneWeek") },
|
||||
{ value: "30d", label: t("period.thirtyDays") },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<BeszelStatsView
|
||||
integrationIds={integrationIds}
|
||||
systemId={selectedSystem}
|
||||
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
|
||||
systemId={selectedSystem?.systemId ?? ""}
|
||||
timePeriod={options.timePeriod as BeszelTimePeriod}
|
||||
columns={width > 600 ? 2 : 1}
|
||||
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
|
||||
visibility={{
|
||||
cpu: options.showCpu,
|
||||
memory: options.showMemory,
|
||||
disk: options.showDisk,
|
||||
diskIO: options.showDiskIO,
|
||||
network: options.showNetwork,
|
||||
dockerCpu: options.showDockerCpu,
|
||||
dockerMemory: options.showDockerMemory,
|
||||
dockerNetwork: options.showDockerNetwork,
|
||||
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
|
||||
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
|
||||
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
|
||||
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
|
||||
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
|
||||
}}
|
||||
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { createBeszelSystemChoices } from "./selection";
|
||||
|
||||
const timePeriodOptions = [
|
||||
{ value: "1m", label: "Live" },
|
||||
@@ -30,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
|
||||
isPending,
|
||||
isError,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
|
||||
const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
|
||||
const selectData = createBeszelSystemChoices(data).map(({ value, label }) => ({ value, label }));
|
||||
return { data: selectData, isPending, isError };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
const choices = createBeszelSystemChoices([
|
||||
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
|
||||
{ integrationId: "two", integrationName: "Backup", systems: [{ id: "same", name: "Host B" }] },
|
||||
]);
|
||||
|
||||
describe("Beszel system selection", () => {
|
||||
it("uses a composite integration and system identity", () => {
|
||||
expect(choices.map((choice) => choice.value)).toEqual(["one:same", "two:same"]);
|
||||
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
|
||||
});
|
||||
|
||||
it("keeps legacy plain system ids working", () => {
|
||||
expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface BeszelSystemChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
integrationId: string;
|
||||
systemId: string;
|
||||
}
|
||||
|
||||
interface BeszelSystemsResult {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
systems: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function createBeszelSystemChoices(results: BeszelSystemsResult[]): BeszelSystemChoice[] {
|
||||
const includeIntegrationName = results.length > 1;
|
||||
return results.flatMap((result) =>
|
||||
result.systems.map((system) => ({
|
||||
value: `${result.integrationId}:${system.id}`,
|
||||
label: includeIntegrationName ? `${result.integrationName} · ${system.name}` : system.name,
|
||||
integrationId: result.integrationId,
|
||||
systemId: system.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
|
||||
return (
|
||||
choices.find((choice) => choice.value === storedValue) ??
|
||||
choices.find((choice) => choice.systemId === storedValue) ??
|
||||
choices[0]
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
||||
import { Box, Center, Group, Loader, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import {
|
||||
Activity,
|
||||
@@ -20,12 +20,14 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
@@ -38,16 +40,24 @@ import {
|
||||
getProgressTrackSize,
|
||||
} from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { getBeszelTableVisibleMetricKeys } from "./display";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
type SystemRowWithKey = BeszelSystemRow & { _key: string };
|
||||
type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string };
|
||||
|
||||
const columnAccessors = [
|
||||
"name",
|
||||
"integrationName",
|
||||
"hostname",
|
||||
"osName",
|
||||
"cpuModel",
|
||||
"cores",
|
||||
"memoryTotal",
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
@@ -59,7 +69,7 @@ const columnAccessors = [
|
||||
"services",
|
||||
"uptime",
|
||||
"agentVersion",
|
||||
] as const satisfies readonly (keyof BeszelSystemRow)[];
|
||||
] as const satisfies readonly (keyof SystemRowWithKey)[];
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
@@ -96,18 +106,23 @@ export default function BeszelSystemTableWidget({
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||
const t = useScopedI18n("widget.beszelSystemTable");
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
|
||||
const systemsData = getUsableWidgetQueryData(systemsQuery);
|
||||
const results = useMemo(() => systemsData ?? [], [systemsData]);
|
||||
const { isPending } = systemsQuery;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const size = useMemo(() => getSizeConfig(width), [width]);
|
||||
const visibleMetricKeys = useMemo(
|
||||
() => getBeszelTableVisibleMetricKeys(options, width, isAdvanced),
|
||||
[options, width, isAdvanced],
|
||||
);
|
||||
|
||||
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
|
||||
onError: () =>
|
||||
@@ -125,6 +140,18 @@ export default function BeszelSystemTableWidget({
|
||||
});
|
||||
|
||||
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
|
||||
const integrationNames = useMemo(
|
||||
() => new Map(results.map((result) => [result.integrationId, result.integrationName])),
|
||||
[results],
|
||||
);
|
||||
const systemsWithSource = useMemo(
|
||||
() =>
|
||||
filteredSystems.map((system) => ({
|
||||
...system,
|
||||
integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—",
|
||||
})),
|
||||
[filteredSystems, integrationNames],
|
||||
);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SystemRowWithKey>>({
|
||||
columnAccessor: options.sortBy,
|
||||
@@ -138,16 +165,16 @@ export default function BeszelSystemTableWidget({
|
||||
}, [options.sortBy, options.sortDirection]);
|
||||
|
||||
const sortedSystems = useMemo(() => {
|
||||
const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow;
|
||||
const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey;
|
||||
const dir = directionMultiplier[sortStatus.direction] ?? 1;
|
||||
return [...filteredSystems].toSorted((a, b) => {
|
||||
return [...systemsWithSource].toSorted((a, b) => {
|
||||
const aVal = a[accessor] ?? 0;
|
||||
const bVal = b[accessor] ?? 0;
|
||||
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir;
|
||||
return 0;
|
||||
});
|
||||
}, [filteredSystems, sortStatus]);
|
||||
}, [systemsWithSource, sortStatus]);
|
||||
|
||||
const PercentCell = ({ value }: { value: number }) => (
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
@@ -178,14 +205,62 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap" style={{ overflow: "hidden", paddingInlineStart: 4 }}>
|
||||
<Indicator color={statusColorMap[record.status]} size={7} />
|
||||
<Box w={7} h={7} bg={statusColorMap[record.status]} style={{ borderRadius: "50%", flexShrink: 0 }} />
|
||||
<Text size={size.fontSize} fw={500} truncate>
|
||||
{record.name}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showCpu && {
|
||||
isAdvanced && {
|
||||
accessor: "integrationName",
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
title: t("column.source"),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
|
||||
},
|
||||
isAdvanced && {
|
||||
accessor: "hostname",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
title: t("column.hostname"),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
|
||||
},
|
||||
isAdvanced && {
|
||||
accessor: "osName",
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
title: t("column.os"),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
|
||||
},
|
||||
isAdvanced && {
|
||||
accessor: "cpuModel",
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
title: t("column.cpuModel"),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
|
||||
},
|
||||
isAdvanced && {
|
||||
accessor: "cores",
|
||||
width: 80,
|
||||
title: t("column.cores"),
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
|
||||
},
|
||||
isAdvanced && {
|
||||
accessor: "memoryTotal",
|
||||
width: 120,
|
||||
title: t("column.memoryTotal"),
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
|
||||
),
|
||||
},
|
||||
visibleMetricKeys.has("showCpu") && {
|
||||
accessor: "cpu",
|
||||
width: 140,
|
||||
title: (
|
||||
@@ -197,7 +272,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.cpu} />,
|
||||
},
|
||||
options.showMemory && {
|
||||
visibleMetricKeys.has("showMemory") && {
|
||||
accessor: "memory",
|
||||
width: 140,
|
||||
title: (
|
||||
@@ -209,7 +284,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.memory} />,
|
||||
},
|
||||
options.showDisk && {
|
||||
visibleMetricKeys.has("showDisk") && {
|
||||
accessor: "disk",
|
||||
width: 160,
|
||||
title: (
|
||||
@@ -229,7 +304,7 @@ export default function BeszelSystemTableWidget({
|
||||
/>
|
||||
),
|
||||
},
|
||||
options.showGpu && {
|
||||
visibleMetricKeys.has("showGpu") && {
|
||||
accessor: "gpu",
|
||||
width: 140,
|
||||
title: (
|
||||
@@ -241,7 +316,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <PercentCell value={record.gpu} />,
|
||||
},
|
||||
options.showLoadAvg && {
|
||||
visibleMetricKeys.has("showLoadAvg") && {
|
||||
accessor: "loadAvg",
|
||||
width: 100,
|
||||
title: (
|
||||
@@ -253,12 +328,17 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Indicator color={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"} size={7} />
|
||||
<Box
|
||||
w={7}
|
||||
h={7}
|
||||
bg={record.loadAvg ? loadAvgColor(record.loadAvg[0], record.cores) : "gray"}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
<Text size={size.fontSize}>{formatLoadAvg(record.loadAvg)}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
options.showNet && {
|
||||
visibleMetricKeys.has("showNet") && {
|
||||
accessor: "netBytes",
|
||||
width: 100,
|
||||
title: (
|
||||
@@ -274,7 +354,7 @@ export default function BeszelSystemTableWidget({
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
options.showTemp && {
|
||||
visibleMetricKeys.has("showTemp") && {
|
||||
accessor: "temp",
|
||||
width: 80,
|
||||
title: (
|
||||
@@ -286,7 +366,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatTemp(record.temp, false)}</Text>,
|
||||
},
|
||||
options.showBattery && {
|
||||
visibleMetricKeys.has("showBattery") && {
|
||||
accessor: "battery",
|
||||
width: 70,
|
||||
title: (
|
||||
@@ -297,7 +377,7 @@ export default function BeszelSystemTableWidget({
|
||||
),
|
||||
render: (record) => <Text size={size.fontSize}>{record.battery ? `${record.battery[0]}%` : "—"}</Text>,
|
||||
},
|
||||
options.showServices && {
|
||||
visibleMetricKeys.has("showServices") && {
|
||||
accessor: "services",
|
||||
width: 80,
|
||||
title: (
|
||||
@@ -309,7 +389,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{record.services}</Text>,
|
||||
},
|
||||
options.showUptime && {
|
||||
visibleMetricKeys.has("showUptime") && {
|
||||
accessor: "uptime",
|
||||
width: 90,
|
||||
title: (
|
||||
@@ -321,7 +401,7 @@ export default function BeszelSystemTableWidget({
|
||||
sortable: true,
|
||||
render: (record) => <Text size={size.fontSize}>{formatUptime(record.uptime)}</Text>,
|
||||
},
|
||||
options.showAgent && {
|
||||
visibleMetricKeys.has("showAgent") && {
|
||||
accessor: "agentVersion",
|
||||
width: 90,
|
||||
title: (
|
||||
@@ -336,21 +416,7 @@ export default function BeszelSystemTableWidget({
|
||||
];
|
||||
|
||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||
}, [
|
||||
options.showAgent,
|
||||
options.showBattery,
|
||||
options.showCpu,
|
||||
options.showDisk,
|
||||
options.showGpu,
|
||||
options.showLoadAvg,
|
||||
options.showMemory,
|
||||
options.showNet,
|
||||
options.showServices,
|
||||
options.showTemp,
|
||||
options.showUptime,
|
||||
size,
|
||||
t,
|
||||
]);
|
||||
}, [t, size, visibleMetricKeys, isAdvanced]);
|
||||
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
@@ -364,14 +430,12 @@ export default function BeszelSystemTableWidget({
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
({ record }: { record: SystemRowWithKey }) => {
|
||||
const integrationId = record._key.split(":")[0] ?? "";
|
||||
const integrationId = record.rowKey.split(":")[0] ?? "";
|
||||
openModal({ integrationId, systemId: record.id }, { title: record.name });
|
||||
},
|
||||
[openModal],
|
||||
);
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -380,10 +444,13 @@ export default function BeszelSystemTableWidget({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const table = (
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<Group gap={0}>
|
||||
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Group>
|
||||
</div>
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
@@ -394,10 +461,12 @@ export default function BeszelSystemTableWidget({
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
idAccessor="rowKey"
|
||||
storeColumnsKey={storeKey}
|
||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return table;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -12,11 +12,11 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
|
||||
export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
|
||||
const board = useRequiredBoard();
|
||||
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
|
||||
select(data) {
|
||||
return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
select(apps) {
|
||||
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +48,16 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
|
||||
{options.title}
|
||||
</Title>
|
||||
)}
|
||||
{(options.layout === "grid" || options.layout === "gridHorizontal") && (
|
||||
{displayMode === "advanced" && (
|
||||
<AdvancedBookmarksLayout
|
||||
data={data}
|
||||
width={width}
|
||||
openNewTab={options.openNewTab}
|
||||
withBorder={options.withBorder}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
{displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
|
||||
<GridLayout
|
||||
data={data}
|
||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
||||
@@ -60,7 +69,7 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
|
||||
hasIconColor={board.iconColor !== null}
|
||||
/>
|
||||
)}
|
||||
{options.layout !== "grid" && options.layout !== "gridHorizontal" && (
|
||||
{displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
|
||||
<FlexLayout
|
||||
data={data}
|
||||
direction={options.layout}
|
||||
@@ -144,6 +153,77 @@ const FlexLayout = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface AdvancedBookmarksLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
width: number;
|
||||
openNewTab: boolean;
|
||||
withBorder: boolean;
|
||||
hasIconColor: boolean;
|
||||
}
|
||||
|
||||
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
|
||||
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
|
||||
|
||||
export const getBookmarkHostname = (href: string | null): string | undefined => {
|
||||
if (!href) return undefined;
|
||||
try {
|
||||
return new URL(href).hostname;
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
};
|
||||
|
||||
const AdvancedBookmarksLayout = ({
|
||||
data,
|
||||
width,
|
||||
openNewTab,
|
||||
withBorder,
|
||||
hasIconColor,
|
||||
}: AdvancedBookmarksLayoutProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
key={app.id}
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target={openNewTab ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<MaskedOrNormalImage
|
||||
imageUrl={app.iconUrl}
|
||||
hasColor={hasIconColor}
|
||||
alt={app.name}
|
||||
className={classes.bookmarkIcon}
|
||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
||||
/>
|
||||
<Stack gap={3} miw={0}>
|
||||
<Text fw={700} size="sm" truncate="end">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Anchor component="span" size="xs" truncate="end">
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
{app.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{app.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
hideTitle: boolean;
|
||||
@@ -247,7 +327,7 @@ const VerticalItem = ({
|
||||
)}
|
||||
{!hideHostname && (
|
||||
<Anchor ta="center" component="span" size="xs">
|
||||
{app.href ? new URL(app.href).hostname : undefined}
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -294,7 +374,7 @@ const HorizontalItem = ({
|
||||
|
||||
{!hideHostname && (
|
||||
<Anchor component="span" size="xs">
|
||||
{app.href ? new URL(app.href).hostname : undefined}
|
||||
{getBookmarkHostname(app.href)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { BookmarkAddButton } from "./add-button";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconBookmark,
|
||||
queryKeys: [[["app", "byIds"]]],
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["app", "byIds"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
|
||||
|
||||
describe("bookmark display helpers", () => {
|
||||
test("uses bounded advanced columns", () => {
|
||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test("does not crash on malformed bookmark URLs", () => {
|
||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
||||
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
|
||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -18,15 +19,25 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { groupEventsByDate } from "./calendar-events";
|
||||
import classes from "./calendar-event-list.module.css";
|
||||
|
||||
interface CalendarEventListProps {
|
||||
events: CalendarEvent[];
|
||||
groupByDate?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
|
||||
const headingIdPrefix = useId();
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
offsetScrollbars
|
||||
@@ -38,129 +49,155 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{t("widget.calendar.duration.allDay")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
<Stack gap="md">
|
||||
{groups ? (
|
||||
groups.map(([date, groupedEvents]) => {
|
||||
const headingId = `${headingIdPrefix}-${date}`;
|
||||
return (
|
||||
<Box key={date} component="section" aria-labelledby={headingId}>
|
||||
<Text id={headingId} component="h3" size="sm" fw={600} tt="capitalize">
|
||||
{dateFormatter.format(groupedEvents[0]?.startDate)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
<Stack mt="xs">
|
||||
<CalendarEventRows events={groupedEvents} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<CalendarEventRows events={events} />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
{isAllDay(event) ? (
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{t("widget.calendar.duration.allDay")}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const isAllDay = (event: Pick<CalendarEvent, "startDate" | "endDate">) => {
|
||||
if (!event.endDate) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
|
||||
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
|
||||
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
|
||||
|
||||
export const getCalendarAgendaEvents = (
|
||||
events: CalendarEvent[],
|
||||
viewedMonth: Date,
|
||||
releaseTypes: readonly string[],
|
||||
): CalendarEvent[] => {
|
||||
const monthStart = dayjs(viewedMonth).startOf("month");
|
||||
const monthEnd = monthStart.add(1, "month");
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (!matchesReleaseType(event, releaseTypes)) return false;
|
||||
|
||||
const start = dayjs(event.startDate);
|
||||
if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
|
||||
|
||||
// Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
|
||||
return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
|
||||
})
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
};
|
||||
|
||||
export const groupEventsByDate = (events: CalendarEvent[]): Map<string, CalendarEvent[]> => {
|
||||
const result = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = toCalendarDateKey(event.startDate);
|
||||
const entries = result.get(key) ?? [];
|
||||
entries.push(event);
|
||||
result.set(key, entries);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...result.entries()]
|
||||
.toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
|
||||
.map(([date, entries]) => [
|
||||
date,
|
||||
entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
/** Splits multi-day events into single-day calendar tiles. */
|
||||
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
|
||||
const splitEventEntries: CalendarEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (!event.endDate) {
|
||||
splitEventEntries.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
|
||||
splitEventEntries.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isAfter(event.endDate)) continue;
|
||||
|
||||
let currentStart = dayjs(event.startDate);
|
||||
while (currentStart.isBefore(event.endDate)) {
|
||||
splitEventEntries.push({
|
||||
...event,
|
||||
startDate: currentStart.toDate(),
|
||||
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
|
||||
});
|
||||
currentStart = currentStart.add(1, "day").startOf("day");
|
||||
}
|
||||
}
|
||||
return splitEventEntries;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
|
||||
import { splitEvents } from "./component";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
|
||||
|
||||
describe("splitEvents should split multi-day events into multiple single-day events", () => {
|
||||
test("2 day all-day event should be split up into two all-day events", () => {
|
||||
@@ -53,6 +53,45 @@ describe("splitEvents should split multi-day events into multiple single-day eve
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupEventsByDate", () => {
|
||||
test("groups once per day and sorts each bucket", () => {
|
||||
const later = createEvent(new Date(2025, 0, 1, 18));
|
||||
const earlier = createEvent(new Date(2025, 0, 1, 9));
|
||||
const nextDay = createEvent(new Date(2025, 0, 2, 9));
|
||||
|
||||
const result = groupEventsByDate([later, nextDay, earlier]);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect([...result.values()][0]).toEqual([earlier, later]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCalendarAgendaEvents", () => {
|
||||
test("keeps overlapping original events once and excludes adjacent-month events", () => {
|
||||
const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
|
||||
const inMonth = createEvent(new Date(2025, 0, 20, 9));
|
||||
const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
|
||||
const nextMonth = createEvent(new Date(2025, 1, 1));
|
||||
|
||||
const result = getCalendarAgendaEvents(
|
||||
[nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
|
||||
new Date(2025, 0, 15),
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual([spansIntoMonth, inMonth]);
|
||||
});
|
||||
|
||||
test("applies Radarr release filters without splitting multi-day events", () => {
|
||||
const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
|
||||
cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
|
||||
const digital = createEvent(new Date(2025, 0, 7));
|
||||
digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
|
||||
|
||||
expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
|
||||
});
|
||||
});
|
||||
|
||||
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
|
||||
title: "Test",
|
||||
subTitle: null,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import dayjs from "dayjs";
|
||||
@@ -14,10 +14,24 @@ import { useSettings } from "@homarr/settings";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const persistedState = props.widgetStateRef?.current?.calendar as { monthTimestamp?: number } | undefined;
|
||||
const [month, setMonth] = useState(() => {
|
||||
const timestamp = persistedState?.monthTimestamp;
|
||||
return typeof timestamp === "number" && Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.widgetStateRef) return;
|
||||
props.widgetStateRef.current = {
|
||||
...props.widgetStateRef.current,
|
||||
calendar: { monthTimestamp: month.getTime() },
|
||||
};
|
||||
}, [month, props.widgetStateRef]);
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
|
||||
@@ -31,7 +45,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -43,7 +57,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
|
||||
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarBaseProps {
|
||||
@@ -52,9 +75,10 @@ interface CalendarBaseProps {
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
@@ -65,124 +89,101 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
const isSmall = width < 256;
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
const visibleEvents = useMemo(
|
||||
() =>
|
||||
normalizedEvents.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
),
|
||||
[normalizedEvents, options.releaseType],
|
||||
);
|
||||
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
|
||||
const agendaEvents = useMemo(
|
||||
() => getCalendarAgendaEvents(events, month, options.releaseType),
|
||||
[events, month, options.releaseType],
|
||||
);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(month) => setMonth(new Date(month))}
|
||||
onNextMonth={(month) => setMonth(new Date(month))}
|
||||
highlightToday
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h="100%"
|
||||
ref={ref}
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
pointerEvents: "none",
|
||||
fontSize: isSmall ? "0.75rem" : undefined,
|
||||
height: "100%",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "md",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
padding: 0,
|
||||
},
|
||||
weekdaysRow: {
|
||||
height: 22,
|
||||
},
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = normalizedEvents
|
||||
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
|
||||
.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
)
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||
onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
|
||||
highlightToday
|
||||
locale={locale}
|
||||
hideWeekdays={false}
|
||||
date={month}
|
||||
maxLevel="month"
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
static={isEditMode}
|
||||
className={classes.calendar}
|
||||
w="100%"
|
||||
h={displayMode === "advanced" ? "62%" : "100%"}
|
||||
styles={{
|
||||
calendarHeaderControl: {
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
borderRadius: "md",
|
||||
height: isSmall ? "1.5rem" : undefined,
|
||||
width: isSmall ? "1.5rem" : undefined,
|
||||
},
|
||||
calendarHeaderLevel: {
|
||||
pointerEvents: "none",
|
||||
fontSize: isSmall ? "0.75rem" : undefined,
|
||||
height: "100%",
|
||||
},
|
||||
levelsGroup: {
|
||||
height: "100%",
|
||||
padding: "md",
|
||||
},
|
||||
calendarHeader: {
|
||||
maxWidth: "unset",
|
||||
marginBottom: 0,
|
||||
},
|
||||
monthCell: {
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
},
|
||||
day: {
|
||||
borderRadius: actualItemRadius,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
month: {
|
||||
height: "100%",
|
||||
},
|
||||
weekday: {
|
||||
padding: 0,
|
||||
},
|
||||
weekdaysRow: {
|
||||
height: 22,
|
||||
},
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
|
||||
|
||||
return (
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<CalendarDay
|
||||
date={dayjs(tileDate).toDate()}
|
||||
events={eventsForDate}
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{displayMode === "advanced" && (
|
||||
<Box px="md" pb="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
|
||||
</Text>
|
||||
<CalendarEventList events={agendaEvents} groupByDate locale={locale} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits multi-day events into multiple single-day events.
|
||||
* @param events The events to split.
|
||||
* @returns The split events.
|
||||
*/
|
||||
export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
|
||||
const splitEvents: CalendarEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (!event.endDate) {
|
||||
splitEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isSame(event.endDate, "day")) {
|
||||
splitEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayjs(event.startDate).isAfter(event.endDate)) {
|
||||
// Invalid event, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Event spans multiple days, split it
|
||||
let currentStart = dayjs(event.startDate);
|
||||
|
||||
while (currentStart.isBefore(event.endDate)) {
|
||||
splitEvents.push({
|
||||
...event,
|
||||
startDate: currentStart.toDate(),
|
||||
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
|
||||
});
|
||||
|
||||
currentStart = currentStart.add(1, "day").startOf("day");
|
||||
}
|
||||
}
|
||||
return splitEvents;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
queryKey: [["widget", "calendar"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import timezones from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { metricToImperial } from "@homarr/common";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
||||
import { WeatherDescription } from "../weather/icon";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezones);
|
||||
|
||||
export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
|
||||
const secondsFormat = options.showSeconds ? ":ss" : "";
|
||||
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
|
||||
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
|
||||
const dateFormat = options.dateFormat;
|
||||
const customTimeFormat = options.customTimeFormat;
|
||||
const customDateFormat = options.customDateFormat;
|
||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const time = useCurrentTime(options);
|
||||
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
|
||||
|
||||
const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
|
||||
const minimumAxis = Math.min(width, height * 1.5);
|
||||
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
|
||||
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||
|
||||
return (
|
||||
@@ -35,7 +42,9 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
<ClockWeatherCorner
|
||||
latitude={options.weatherLocation.latitude}
|
||||
longitude={options.weatherLocation.longitude}
|
||||
locationName={options.weatherLocation.name}
|
||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||
showDetails={isAdvanced}
|
||||
/>
|
||||
)}
|
||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||
@@ -45,13 +54,20 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
</Text>
|
||||
)}
|
||||
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
|
||||
<time dateTime={zonedTime?.toISOString()}>
|
||||
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
|
||||
</time>
|
||||
</Title>
|
||||
{options.showDate && (
|
||||
{(isAdvanced || options.showDate) && (
|
||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
|
||||
</Text>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
@@ -60,28 +76,86 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
|
||||
interface ClockWeatherCornerProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locationName: string;
|
||||
isFahrenheit: boolean;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
|
||||
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const ClockWeatherCorner = ({
|
||||
latitude,
|
||||
longitude,
|
||||
locationName,
|
||||
isFahrenheit,
|
||||
showDetails,
|
||||
}: ClockWeatherCornerProps) => {
|
||||
const t = useI18n();
|
||||
const tCommon = useScopedI18n("common");
|
||||
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
|
||||
const weather = weatherQuery.data;
|
||||
|
||||
if (!weather) return null;
|
||||
if (!weather) {
|
||||
return (
|
||||
<Box pos="absolute" top={4} left={4}>
|
||||
{isInitialWidgetQueryPending(weatherQuery) ? (
|
||||
<Loader size="xs" aria-label={tCommon("action.loading")} />
|
||||
) : (
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
|
||||
const unit = isFahrenheit ? "°F" : "°C";
|
||||
const today = weather.daily[0];
|
||||
const formatTemperature = (value: number | undefined) => {
|
||||
if (value === undefined) return "—";
|
||||
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
|
||||
return `${Math.round(preferred)}${unit}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="clock-weather-corner" pos="absolute" top={4} left={4} gap={0} align="center">
|
||||
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
|
||||
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
<Stack
|
||||
className="clock-weather-corner"
|
||||
pos="absolute"
|
||||
top={4}
|
||||
left={4}
|
||||
gap={showDetails ? 4 : 0}
|
||||
align="flex-start"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
|
||||
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
|
||||
{Math.round(temp)}
|
||||
{unit}
|
||||
</Text>
|
||||
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
|
||||
</Group>
|
||||
{showDetails && (
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" fw={600}>
|
||||
{locationName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
|
||||
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
|
||||
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
|
||||
|
||||
interface UseCurrentTimeProps {
|
||||
showSeconds: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils";
|
||||
import { InstanceCard } from "./instance-card";
|
||||
import { SingleInstanceLayout } from "./single-instance-layout";
|
||||
|
||||
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
|
||||
export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
|
||||
if (integrationIds.length === 0) {
|
||||
@@ -21,29 +26,45 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
|
||||
);
|
||||
}
|
||||
|
||||
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
|
||||
return (
|
||||
<CoolifyContent
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CoolifyContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
|
||||
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
|
||||
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
|
||||
|
||||
const isTiny = width < 256;
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const [firstInstance] = instancesData;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
if (instancesData.length === 1 && firstInstance) {
|
||||
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
|
||||
return (
|
||||
<SingleInstanceLayout
|
||||
instance={firstInstance}
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs" p="xs">
|
||||
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
|
||||
{instancesData.map((instance) => (
|
||||
<InstanceCard
|
||||
key={instance.integrationId}
|
||||
@@ -51,9 +72,10 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
|
||||
options={options}
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
|
||||
icon: IconCloud,
|
||||
queryKey: [["widget", "coolify"]],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showServers: factory.switch({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
@@ -18,9 +19,10 @@ interface InstanceCardProps {
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
|
||||
export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const cardKey = `${widgetKey}-${instance.integrationId}`;
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
@@ -31,6 +33,12 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
key: `coolify-sections-${cardKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = [
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
@@ -90,7 +98,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
|
||||
<Accordion
|
||||
variant="filled"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
value={isAdvanced ? advancedOpenSections : openSections}
|
||||
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
|
||||
>
|
||||
{options.showServers && (
|
||||
<ServersSection
|
||||
servers={instance.instanceInfo.servers}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
@@ -18,9 +19,10 @@ interface SingleInstanceLayoutProps {
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isAdvanced }: SingleInstanceLayoutProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${widgetKey}`,
|
||||
@@ -30,6 +32,12 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
|
||||
key: `coolify-sections-${widgetKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = [
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
|
||||
const serverResourceCounts = buildServerResourceCounts(
|
||||
instance.instanceInfo.servers,
|
||||
@@ -56,7 +64,13 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
|
||||
<Accordion
|
||||
variant="contained"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
value={isAdvanced ? advancedOpenSections : openSections}
|
||||
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
|
||||
>
|
||||
{options.showServers && (
|
||||
<ServersSection
|
||||
servers={instance.instanceInfo.servers}
|
||||
|
||||
@@ -87,7 +87,6 @@ export default function CustomApiWidget({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Unavailable({
|
||||
message,
|
||||
danger = false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
@@ -6,6 +6,22 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
|
||||
icon: IconApi,
|
||||
queryKey: [["widget", "customApi"]],
|
||||
contextActions({ options, widgetStateRef }) {
|
||||
return [
|
||||
{
|
||||
key: "togglePolling",
|
||||
label: (t) => t("widget.customApi.actions.togglePolling"),
|
||||
icon: IconPlayerPause,
|
||||
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
|
||||
disabled: typeof widgetStateRef.current?.togglePolling !== "function",
|
||||
onClick: () => {
|
||||
const action = widgetStateRef.current?.togglePolling;
|
||||
if (typeof action === "function") action();
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getWidgetQueryKeys } from "./definition";
|
||||
|
||||
describe("getWidgetQueryKeys", () => {
|
||||
test("falls back to the widget router namespace", () => {
|
||||
expect(getWidgetQueryKeys({ kind: "example" })).toEqual([[["widget", "example"]]]);
|
||||
});
|
||||
|
||||
test("wraps a singular explicit key", () => {
|
||||
expect(getWidgetQueryKeys({ kind: "example", queryKey: [["app", "byId"]] })).toEqual([[["app", "byId"]]]);
|
||||
});
|
||||
|
||||
test("preserves every explicit key for multi-root widgets", () => {
|
||||
const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
|
||||
|
||||
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from "react";
|
||||
import type { LoaderComponent } from "next/dynamic";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { hashKey } from "@tanstack/react-query";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
@@ -10,7 +11,7 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
|
||||
|
||||
export interface WidgetContextMenuAction {
|
||||
key: string;
|
||||
@@ -26,18 +27,50 @@ export interface WidgetContextMenuContext {
|
||||
isEditMode: boolean;
|
||||
boardId: string | undefined;
|
||||
itemId: string | undefined;
|
||||
canInteractWithSelectedIntegrations: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetContextActionProps<
|
||||
TKind extends WidgetKind,
|
||||
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
|
||||
> {
|
||||
kind: TKind;
|
||||
options: inferOptionsFromDefinition<TOptions>;
|
||||
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
|
||||
export interface WidgetRuntimeActions {
|
||||
togglePolling?: () => void;
|
||||
testAllIndexers?: () => void;
|
||||
previousPhoto?: () => void;
|
||||
nextPhoto?: () => void;
|
||||
toggleSlideshow?: () => void;
|
||||
}
|
||||
|
||||
export interface WidgetRuntimeState {
|
||||
queries: readonly NormalizedWidgetQuery[];
|
||||
actions: WidgetRuntimeActions;
|
||||
}
|
||||
|
||||
export type WidgetRuntimeRef = React.MutableRefObject<WidgetRuntimeState>;
|
||||
|
||||
export const createWidgetRuntimeState = (): WidgetRuntimeState => ({
|
||||
queries: [],
|
||||
actions: {},
|
||||
});
|
||||
|
||||
export interface NormalizedWidgetQuery {
|
||||
path: readonly string[];
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface WidgetQueryMatcherScope {
|
||||
itemId: string;
|
||||
boardId: string | undefined;
|
||||
integrationIds: readonly string[];
|
||||
options: Record<string, unknown>;
|
||||
runtimeQueries: readonly NormalizedWidgetQuery[];
|
||||
}
|
||||
|
||||
export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean;
|
||||
|
||||
export interface WidgetContextActionProps {
|
||||
options: Record<string, unknown>;
|
||||
setOptions: (partial: Record<string, unknown>) => void;
|
||||
integrationIds: string[];
|
||||
context: WidgetContextMenuContext;
|
||||
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
}
|
||||
|
||||
const createWithDynamicImport =
|
||||
@@ -69,8 +102,10 @@ export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition ext
|
||||
|
||||
export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
supportsAdvancedFocus?: boolean;
|
||||
queryKey?: QueryKey;
|
||||
queryKeys?: QueryKey[];
|
||||
queryKeys?: readonly QueryKey[];
|
||||
queryMatcher?: WidgetQueryMatcher;
|
||||
refetchInterval?: number | null;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
@@ -88,11 +123,49 @@ export interface WidgetDefinition {
|
||||
}
|
||||
>
|
||||
>;
|
||||
contextActions?: (props: Omit<WidgetContextActionProps<WidgetKind>, "kind">) => WidgetContextMenuAction[];
|
||||
contextActions?: (props: WidgetContextActionProps) => WidgetContextMenuAction[];
|
||||
}
|
||||
|
||||
export const getWidgetQueryKeys = (definition: WidgetDefinition, kind: WidgetKind): QueryKey[] =>
|
||||
definition.queryKeys ?? [definition.queryKey ?? [["widget", kind]]];
|
||||
export const supportsAdvancedFocus = (definition: object) =>
|
||||
"supportsAdvancedFocus" in definition && definition.supportsAdvancedFocus === true;
|
||||
|
||||
export const getWidgetQueryKeys = (
|
||||
definition: {
|
||||
kind?: string;
|
||||
queryKey?: QueryKey;
|
||||
queryKeys?: readonly QueryKey[];
|
||||
},
|
||||
kind?: WidgetKind,
|
||||
): readonly QueryKey[] => {
|
||||
if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
|
||||
return [definition.queryKey ?? [["widget", definition.kind ?? kind]]];
|
||||
};
|
||||
|
||||
export const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
|
||||
const path = queryKey[0];
|
||||
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
|
||||
|
||||
const queryKeyOptions = queryKey[1];
|
||||
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
|
||||
return { path, input };
|
||||
};
|
||||
|
||||
export const getWidgetRuntimeQueries = (widgetRuntimeRef: WidgetRuntimeRef): readonly NormalizedWidgetQuery[] =>
|
||||
widgetRuntimeRef.current.queries;
|
||||
|
||||
export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) =>
|
||||
scope.runtimeQueries.some(
|
||||
(runtimeQuery) =>
|
||||
widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input),
|
||||
);
|
||||
|
||||
export const widgetQueryInputMatches = (input: unknown, expected: Record<string, unknown>) =>
|
||||
isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value));
|
||||
|
||||
export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
|
||||
@@ -103,9 +176,11 @@ export interface WidgetProps<TKind extends WidgetKind> {
|
||||
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
displayMode?: "compact" | "advanced";
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetRuntimeRef?: WidgetRuntimeRef;
|
||||
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
|
||||
removeItem?: () => void;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function DnsHoleControlsWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const board = useRequiredBoard();
|
||||
// DnsHole integrations with interaction permissions
|
||||
@@ -56,13 +58,21 @@ export default function DnsHoleControlsWidget({
|
||||
});
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
|
||||
const {
|
||||
mutateAsync: enableDns,
|
||||
isPending: isEnabling,
|
||||
error: enableError,
|
||||
} = clientApi.widget.dnsHole.enable.useMutation({
|
||||
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
|
||||
});
|
||||
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
|
||||
const {
|
||||
mutateAsync: disableDns,
|
||||
isPending: isDisabling,
|
||||
error: disableError,
|
||||
} = clientApi.widget.dnsHole.disable.useMutation({
|
||||
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
|
||||
});
|
||||
const toggleDns = (integrationId: string) => {
|
||||
const toggleDns = async (integrationId: string) => {
|
||||
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
||||
if (!integrationStatus?.summary.status) return;
|
||||
utils.widget.dnsHole.summary.setData(
|
||||
@@ -86,9 +96,9 @@ export default function DnsHoleControlsWidget({
|
||||
},
|
||||
);
|
||||
if (integrationStatus.summary.status === "enabled") {
|
||||
disableDns({ integrationId, duration: 0 });
|
||||
await disableDns({ integrationId, duration: 0 });
|
||||
} else {
|
||||
enableDns({ integrationId });
|
||||
await enableDns({ integrationId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,9 +113,21 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
// Timer modal setup
|
||||
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]);
|
||||
const [bulkPending, setBulkPending] = useState(false);
|
||||
const [bulkFailureCount, setBulkFailureCount] = useState(0);
|
||||
const [opened, { close, open }] = useDisclosure(false);
|
||||
|
||||
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
|
||||
const controlAllButtonsVisible =
|
||||
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
|
||||
const actionsPending = bulkPending || isEnabling || isDisabling;
|
||||
const actionError = enableError ?? disableError;
|
||||
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
|
||||
setBulkPending(true);
|
||||
setBulkFailureCount(0);
|
||||
const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns));
|
||||
setBulkFailureCount(results.filter(({ status }) => status === "rejected").length);
|
||||
setBulkPending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -123,8 +145,8 @@ export default function DnsHoleControlsWidget({
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-enable-all-button"
|
||||
onClick={() => integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
|
||||
disabled={integrationsSummaries.disabled.length === 0}
|
||||
onClick={() => void runBulkToggle(integrationsSummaries.disabled)}
|
||||
disabled={integrationsSummaries.disabled.length === 0 || actionsPending}
|
||||
variant="light"
|
||||
color="green"
|
||||
bd={0}
|
||||
@@ -144,7 +166,7 @@ export default function DnsHoleControlsWidget({
|
||||
setSelectedIntegrationIds(integrationsSummaries.enabled);
|
||||
open();
|
||||
}}
|
||||
disabled={integrationsSummaries.enabled.length === 0}
|
||||
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
|
||||
variant="light"
|
||||
color="yellow"
|
||||
bd={0}
|
||||
@@ -160,8 +182,8 @@ export default function DnsHoleControlsWidget({
|
||||
size="xs"
|
||||
p={0}
|
||||
className="dns-hole-controls-disable-all-button"
|
||||
onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
|
||||
disabled={integrationsSummaries.enabled.length === 0}
|
||||
onClick={() => void runBulkToggle(integrationsSummaries.enabled)}
|
||||
disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
|
||||
variant="light"
|
||||
color="red"
|
||||
bd={0}
|
||||
@@ -192,16 +214,32 @@ export default function DnsHoleControlsWidget({
|
||||
t={t}
|
||||
hasIconColor={board.iconColor !== null}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
actionsPending={actionsPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{bulkFailureCount > 0 && (
|
||||
<Text size="xs" c="red" ta="center">
|
||||
{t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
|
||||
</Text>
|
||||
)}
|
||||
{actionError && (
|
||||
<Tooltip label={actionError.message} multiline>
|
||||
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
|
||||
{t("widget.dnsHoleControls.error.internalServerError")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<TimerModal
|
||||
opened={opened}
|
||||
close={close}
|
||||
selectedIntegrationIds={selectedIntegrationIds}
|
||||
disableDns={disableDns}
|
||||
disableDns={(input) => void disableDns(input).catch(() => undefined)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
@@ -209,13 +247,16 @@ export default function DnsHoleControlsWidget({
|
||||
|
||||
interface ControlsCardProps {
|
||||
integrationsWithInteractions: string[];
|
||||
toggleDns: (integrationId: string) => void;
|
||||
toggleDns: (integrationId: string) => Promise<void>;
|
||||
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
|
||||
setSelectedIntegrationIds: (integrationId: string[]) => void;
|
||||
open: () => void;
|
||||
t: TranslationFunction;
|
||||
hasIconColor: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
isAdvanced: boolean;
|
||||
actionsPending: boolean;
|
||||
}
|
||||
|
||||
const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
@@ -227,16 +268,19 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
t,
|
||||
hasIconColor,
|
||||
rootWidth,
|
||||
rootHeight,
|
||||
isAdvanced,
|
||||
actionsPending,
|
||||
}) => {
|
||||
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
|
||||
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
|
||||
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
|
||||
// Use all factors to infer the state of the action buttons
|
||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending;
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||
const layout = rootWidth < 256 ? "sm" : "md";
|
||||
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
@@ -296,7 +340,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
<Group gap="xs" grow wrap="nowrap" w="100%">
|
||||
{!isEnabled ? (
|
||||
<ActionIcon
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
disabled={!controlEnabled}
|
||||
size="sm"
|
||||
color="green"
|
||||
@@ -306,7 +350,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
disabled={!controlEnabled}
|
||||
size="sm"
|
||||
color="red"
|
||||
@@ -323,6 +367,8 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
size="sm"
|
||||
color="yellow"
|
||||
variant="light"
|
||||
display={isInteractPermitted ? undefined : "none"}
|
||||
disabled={!controlEnabled || !isEnabled}
|
||||
>
|
||||
<IconClockPause size={12} />
|
||||
</ActionIcon>
|
||||
@@ -334,7 +380,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
||||
disabled={!controlEnabled}
|
||||
display="contents"
|
||||
style={{ cursor: controlEnabled ? "pointer" : "default" }}
|
||||
onClick={() => toggleDns(data.integration.id)}
|
||||
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
|
||||
>
|
||||
<Badge
|
||||
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Badge,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipFloating,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||
|
||||
@@ -19,7 +31,11 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import type { widgetKind } from ".";
|
||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||
|
||||
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
||||
export default function DnsHoleSummaryWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<typeof widgetKind>) {
|
||||
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
@@ -27,28 +43,44 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
||||
const t = useI18n();
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const layoutProps = boxPropsByLayout(options.layout);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="xs" h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
|
||||
{data.length > 0 ? (
|
||||
stats.map((item) => (
|
||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||
))
|
||||
) : (
|
||||
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
|
||||
<AvatarGroup spacing="md">
|
||||
{summaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Text fz="md" ta="center">
|
||||
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack h="100%" gap={0}>
|
||||
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
|
||||
{data.length > 0 ? (
|
||||
stats.map((item) => (
|
||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||
))
|
||||
) : (
|
||||
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
|
||||
<AvatarGroup spacing="md">
|
||||
{summaries.map(({ integration }) => (
|
||||
<Tooltip key={integration.id} label={integration.name}>
|
||||
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Text fz="md" ta="center">
|
||||
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{displayMode === "advanced" && summaries.length > 0 && (
|
||||
<Group px="xs" pb="xs" gap="xs">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Badge
|
||||
key={integration.id}
|
||||
variant="light"
|
||||
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
|
||||
>
|
||||
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
getDefaultZIndex,
|
||||
Group,
|
||||
Menu,
|
||||
Portal,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCategoryPlus,
|
||||
@@ -29,8 +43,11 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import actionTargetClasses from "../common/action-target.module.css";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
type ContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
@@ -63,10 +80,32 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ContainerStateDot = ({ state }: { state: ContainerState }) => {
|
||||
const t = useScopedI18n("docker.field.state.option");
|
||||
const label = t(state);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
w={8}
|
||||
h={8}
|
||||
bg={`${containerStateColorMap[state]}.6`}
|
||||
style={{ borderRadius: "50%" }}
|
||||
/>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const createColumns = (
|
||||
t: ReturnType<typeof useScopedI18n<"docker">>,
|
||||
handlers: ContainerActionHandlers,
|
||||
sortingEnabled: boolean,
|
||||
inlineState: boolean,
|
||||
): DataTableColumn<DockerContainer>[] => [
|
||||
{
|
||||
accessor: "name",
|
||||
@@ -76,6 +115,7 @@ const createColumns = (
|
||||
sortable: sortingEnabled,
|
||||
render: (container) => (
|
||||
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
{inlineState && <ContainerStateDot state={container.state} />}
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
@@ -188,6 +228,7 @@ export default function DockerWidget({
|
||||
options,
|
||||
width,
|
||||
isEditMode,
|
||||
displayMode,
|
||||
boardId,
|
||||
itemId,
|
||||
setOptions,
|
||||
@@ -198,9 +239,11 @@ export default function DockerWidget({
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const isTiny = width <= 256;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery();
|
||||
const data = getUsableWidgetQueryData(containersQuery);
|
||||
const { refetch, isFetching } = containersQuery;
|
||||
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
|
||||
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
|
||||
const relativeTime = useTimeAgo(timestamp);
|
||||
@@ -279,13 +322,18 @@ export default function DockerWidget({
|
||||
[containers],
|
||||
);
|
||||
|
||||
const selectedColumns = useMemo(() => new Set<string>(options.columns), [options.columns]);
|
||||
const columnVisibility = useMemo(
|
||||
() => getDockerColumnVisibility(options.columns, width, isAdvanced),
|
||||
[isAdvanced, options.columns, width],
|
||||
);
|
||||
const inlineState =
|
||||
!isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state");
|
||||
const columns = useMemo(() => {
|
||||
const sortingEnabled = options.enableRowSorting && !isEditMode;
|
||||
return createColumns(t, actionHandlers, sortingEnabled).filter(({ accessor }) =>
|
||||
selectedColumns.has(String(accessor)),
|
||||
const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode;
|
||||
return createColumns(t, actionHandlers, sortingEnabled, inlineState).filter(
|
||||
({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility],
|
||||
);
|
||||
}, [actionHandlers, isEditMode, options.enableRowSorting, selectedColumns, t]);
|
||||
}, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t]);
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
columnAccessors,
|
||||
@@ -303,7 +351,7 @@ export default function DockerWidget({
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, container: record });
|
||||
}, []);
|
||||
|
||||
if (options.columns.length === 0) {
|
||||
if (!isAdvanced && options.columns.length === 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text>{t("error.noColumns")}</Text>
|
||||
@@ -311,6 +359,8 @@ export default function DockerWidget({
|
||||
);
|
||||
}
|
||||
|
||||
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
@@ -325,7 +375,7 @@ export default function DockerWidget({
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={storeKey}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={options.enableRowSorting && !isEditMode ? setSortStatus : undefined}
|
||||
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
|
||||
idAccessor="id"
|
||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||
onScroll={() => {
|
||||
@@ -334,8 +384,15 @@ export default function DockerWidget({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isTiny && (
|
||||
<Group justify="space-between" style={{ borderTop: "0.0625rem solid var(--border-color)" }} p={4} wrap="nowrap">
|
||||
{footerVisibility.footer && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: "0.0625rem solid var(--border-color)",
|
||||
}}
|
||||
p={4}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconBrandDocker size={20} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" truncate>
|
||||
@@ -344,14 +401,19 @@ export default function DockerWidget({
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
||||
</Text>
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
|
||||
</Text>
|
||||
{footerVisibility.cpu && (
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
||||
</Text>
|
||||
)}
|
||||
{footerVisibility.memory && (
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
c="var(--mantine-color-text)"
|
||||
@@ -386,6 +448,7 @@ function ContainerMenuButton({
|
||||
<Menu opened={opened} onChange={setOpened} closeOnItemClick={false} withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
className={actionTargetClasses.root}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
@@ -486,7 +549,7 @@ function ContainerContextMenu({
|
||||
left={0}
|
||||
w="100vw"
|
||||
h="100vh"
|
||||
style={{ zIndex: 1000 }}
|
||||
style={{ zIndex: getDefaultZIndex("popover") }}
|
||||
onClick={onClose}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDockerColumnVisibility } from "./layout";
|
||||
|
||||
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
|
||||
describe("getDockerColumnVisibility", () => {
|
||||
test("keeps only essential columns in a narrow compact widget", () => {
|
||||
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
|
||||
name: true,
|
||||
state: true,
|
||||
host: false,
|
||||
cpuUsage: false,
|
||||
memoryUsage: false,
|
||||
actions: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals compact metrics as width becomes available", () => {
|
||||
expect(getDockerColumnVisibility(columns, 380, false)).toMatchObject({
|
||||
host: false,
|
||||
cpuUsage: true,
|
||||
memoryUsage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("uses every expert column in advanced mode", () => {
|
||||
expect(getDockerColumnVisibility([], 240, true)).toEqual({
|
||||
name: true,
|
||||
state: true,
|
||||
host: true,
|
||||
cpuUsage: true,
|
||||
memoryUsage: true,
|
||||
actions: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage" | "actions";
|
||||
|
||||
export const getDockerColumnVisibility = (
|
||||
configuredColumns: readonly DockerColumn[],
|
||||
width: number,
|
||||
isAdvanced: boolean,
|
||||
): Record<DockerColumn, boolean> => {
|
||||
const configured = new Set(configuredColumns);
|
||||
const isVisible = (column: DockerColumn) => isAdvanced || configured.has(column);
|
||||
|
||||
return {
|
||||
name: isVisible("name"),
|
||||
state: isVisible("state"),
|
||||
host: isVisible("host") && (isAdvanced || width >= 440),
|
||||
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 280),
|
||||
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 360),
|
||||
actions: isVisible("actions"),
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
import "../widgets-common.css";
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
@@ -50,11 +50,21 @@ import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/co
|
||||
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import { HomarrDataTable } from "../common/homarr-data-table";
|
||||
import { formatLocalizedDateTime } from "../common/locale";
|
||||
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
||||
import {
|
||||
DOWNLOAD_COLUMN_ACCESSORS,
|
||||
filterDownloadItemsByStatus,
|
||||
getAvailableDownloadStates,
|
||||
getDownloadColumnAccessors,
|
||||
} from "./helpers";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -81,6 +91,7 @@ interface ColumnContext {
|
||||
hasTorrents: boolean;
|
||||
hasMultipleClients: boolean;
|
||||
hasMultipleTypes: boolean;
|
||||
isAdvanced: boolean;
|
||||
size: SizeConfig;
|
||||
}
|
||||
|
||||
@@ -132,16 +143,16 @@ const stateColorMap: Record<DownloadState, string> = {
|
||||
};
|
||||
|
||||
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
|
||||
failed: "rgba(255, 0, 0, 0.04)",
|
||||
paused: "rgba(255, 200, 0, 0.03)",
|
||||
failed: "var(--mantine-color-red-light)",
|
||||
paused: "var(--mantine-color-yellow-light)",
|
||||
};
|
||||
|
||||
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
||||
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
||||
ratio: (ctx) => ctx.hasTorrents,
|
||||
sent: (ctx) => ctx.hasTorrents,
|
||||
integration: (ctx) => ctx.hasMultipleClients,
|
||||
type: (ctx) => ctx.hasMultipleTypes,
|
||||
integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients,
|
||||
type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes,
|
||||
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
||||
time: (ctx) => ctx.size.showTimeColumn,
|
||||
state: (ctx) => ctx.size.showStateColumn,
|
||||
@@ -152,7 +163,10 @@ const speedColumnConfig = {
|
||||
up: { Icon: IconUpload, color: "green" as const },
|
||||
} as const;
|
||||
|
||||
function getSizeConfig(width: number): SizeConfig {
|
||||
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
|
||||
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
|
||||
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
|
||||
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
|
||||
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
||||
if (width < maxWidth) return config;
|
||||
}
|
||||
@@ -165,24 +179,6 @@ function progressColor(state: DownloadState, progress: number): string {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
const columnAccessors = [
|
||||
"name",
|
||||
"progress",
|
||||
"size",
|
||||
"downSpeed",
|
||||
"upSpeed",
|
||||
"time",
|
||||
"state",
|
||||
"added",
|
||||
"ratio",
|
||||
"received",
|
||||
"sent",
|
||||
"category",
|
||||
"integration",
|
||||
"index",
|
||||
"type",
|
||||
] as const;
|
||||
|
||||
function toCategoryArray(category: string | string[]): string[] {
|
||||
if (Array.isArray(category)) return category;
|
||||
return [category];
|
||||
@@ -298,31 +294,39 @@ export default function DownloadClientsWidget({
|
||||
boardId,
|
||||
itemId,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"downloads">) {
|
||||
const allInteractAccess = useIntegrationsWithInteractAccess();
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const allInteractAccess = useIntegrationsWithInteractAccess();
|
||||
const integrationInteractSet = useMemo(
|
||||
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
|
||||
[allInteractAccess, integrationIds],
|
||||
);
|
||||
|
||||
const {
|
||||
data: currentItems = [],
|
||||
isFetching,
|
||||
isError,
|
||||
} = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
|
||||
const downloadsQuery = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
|
||||
integrationIds,
|
||||
limitPerIntegration: options.limitPerIntegration,
|
||||
});
|
||||
const currentItems = getUsableWidgetQueryData(downloadsQuery);
|
||||
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
|
||||
const { isFetching } = downloadsQuery;
|
||||
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
const queryIndicators = (
|
||||
<Group gap={0}>
|
||||
<IntegrationErrorIndicator results={currentItems ?? []} />
|
||||
<WidgetQueryErrorIndicator error={downloadsQuery.error} label={t("name")} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [showStats, { toggle: toggleStats }] = useDisclosure(false);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const mutationOptions = {
|
||||
@@ -336,6 +340,7 @@ export default function DownloadClientsWidget({
|
||||
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(mutationOptions);
|
||||
|
||||
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
|
||||
scope: { id: `downloads-options:${boardId ?? "preview"}:${itemId ?? "preview"}` },
|
||||
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
||||
});
|
||||
const persistOption = useTableLayoutPersistence({
|
||||
@@ -348,26 +353,27 @@ export default function DownloadClientsWidget({
|
||||
|
||||
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
|
||||
if (options.descendingDefaultSort) defaultSortDirection = "desc";
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>({
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>(() => ({
|
||||
columnAccessor: options.defaultSort,
|
||||
direction: defaultSortDirection,
|
||||
});
|
||||
}));
|
||||
const hasMountedSortDefaults = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasMountedSortDefaults.current) {
|
||||
hasMountedSortDefaults.current = true;
|
||||
return;
|
||||
}
|
||||
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
||||
}, [options.defaultSort, defaultSortDirection]);
|
||||
|
||||
const data = useMemo<ExtendedDownloadClientItem[]>(() => {
|
||||
return currentItems
|
||||
const eligibleData = useMemo<ExtendedDownloadClientItem[]>(() => {
|
||||
return availableItems
|
||||
.filter(({ integration }) => integrationIds.includes(integration.id))
|
||||
.flatMap((pair) =>
|
||||
pair.data.items
|
||||
.filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist))
|
||||
.filter((item) => showCompletedItem(item, options))
|
||||
.filter(
|
||||
({ state }) =>
|
||||
(clientFilter.length === 0 || clientFilter.includes(pair.integration.id)) &&
|
||||
(statusFilter.length === 0 || statusFilter.includes(state)),
|
||||
)
|
||||
.filter(() => clientFilter.length === 0 || clientFilter.includes(pair.integration.id))
|
||||
.map((item): ExtendedDownloadClientItem => {
|
||||
const received = item.received ?? Math.floor(item.size * item.progress);
|
||||
const ids = [pair.integration.id];
|
||||
@@ -390,7 +396,7 @@ export default function DownloadClientsWidget({
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
currentItems,
|
||||
availableItems,
|
||||
integrationIds,
|
||||
integrationInteractSet,
|
||||
mutateDeleteItem,
|
||||
@@ -398,9 +404,10 @@ export default function DownloadClientsWidget({
|
||||
mutateResumeItem,
|
||||
options,
|
||||
clientFilter,
|
||||
statusFilter,
|
||||
]);
|
||||
|
||||
const data = useMemo(() => filterDownloadItemsByStatus(eligibleData, statusFilter), [eligibleData, statusFilter]);
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem;
|
||||
const multiplier = getSortMultiplier(sortStatus.direction);
|
||||
@@ -412,7 +419,7 @@ export default function DownloadClientsWidget({
|
||||
}, [data, sortStatus]);
|
||||
|
||||
const clients = useMemo<ExtendedClientStatus[]>(() => {
|
||||
return currentItems
|
||||
return availableItems
|
||||
.filter(({ integration }) => integrationIds.includes(integration.id))
|
||||
.map(({ integration, data: clientData }): ExtendedClientStatus => {
|
||||
const interact = integrationInteractSet.has(integration.id);
|
||||
@@ -449,7 +456,7 @@ export default function DownloadClientsWidget({
|
||||
};
|
||||
});
|
||||
}, [
|
||||
currentItems,
|
||||
availableItems,
|
||||
integrationIds,
|
||||
integrationInteractSet,
|
||||
options.applyFilterToRatio,
|
||||
@@ -461,11 +468,14 @@ export default function DownloadClientsWidget({
|
||||
const hasMultipleClients = clients.length > 1;
|
||||
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
|
||||
|
||||
const size = useMemo(() => getSizeConfig(width), [width]);
|
||||
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
|
||||
const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
|
||||
const optionsColumnSet = useMemo(
|
||||
() => new Set<string>(getDownloadColumnAccessors(options.columns, isAdvanced)),
|
||||
[isAdvanced, options.columns],
|
||||
);
|
||||
const columnContext = useMemo<ColumnContext>(
|
||||
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
|
||||
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size],
|
||||
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }),
|
||||
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size],
|
||||
);
|
||||
|
||||
let progressColumnWidth = 120;
|
||||
@@ -479,7 +489,7 @@ export default function DownloadClientsWidget({
|
||||
title: "",
|
||||
width: 36,
|
||||
render: (record) => (
|
||||
<Tooltip label={record.integration.name} withArrow>
|
||||
<Tooltip key={displayMode} label={record.integration.name} withArrow>
|
||||
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -491,6 +501,7 @@ export default function DownloadClientsWidget({
|
||||
ellipsis: true,
|
||||
render: (record) => (
|
||||
<Tooltip
|
||||
key={displayMode}
|
||||
label={buildHoverTooltip(record, t)}
|
||||
multiline
|
||||
w={280}
|
||||
@@ -525,7 +536,14 @@ export default function DownloadClientsWidget({
|
||||
render: (record) => {
|
||||
const pct = Math.floor(record.progress * 100);
|
||||
return (
|
||||
<Tooltip label={buildProgressTooltip(record)} withArrow openDelay={300} position="top" color="dark">
|
||||
<Tooltip
|
||||
key={displayMode}
|
||||
label={buildProgressTooltip(record)}
|
||||
withArrow
|
||||
openDelay={300}
|
||||
position="top"
|
||||
color="dark"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} w={36} ta="right" style={{ flexShrink: 0 }}>
|
||||
{`${pct}%`}
|
||||
@@ -666,11 +684,11 @@ export default function DownloadClientsWidget({
|
||||
},
|
||||
];
|
||||
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
|
||||
}, [columnContext, t, size, progressColumnWidth]);
|
||||
}, [columnContext, t, size, progressColumnWidth, displayMode]);
|
||||
|
||||
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
||||
columns,
|
||||
columnAccessors,
|
||||
columnAccessors: DOWNLOAD_COLUMN_ACCESSORS,
|
||||
columnOrder: options.columnOrder,
|
||||
columnWidths: options.columnWidths,
|
||||
itemId,
|
||||
@@ -701,7 +719,7 @@ export default function DownloadClientsWidget({
|
||||
return traffic.up / traffic.down;
|
||||
}, [clients]);
|
||||
|
||||
const availableStatuses = useMemo(() => [...new Set(data.map(({ state }) => state))], [data]);
|
||||
const availableStatuses = useMemo(() => getAvailableDownloadStates(eligibleData), [eligibleData]);
|
||||
|
||||
const queueStats = useMemo(() => {
|
||||
const stateCounts: Record<string, number> = {};
|
||||
@@ -715,19 +733,16 @@ export default function DownloadClientsWidget({
|
||||
return { stateCounts, totalSize, completedSize, totalItems: data.length };
|
||||
}, [data]);
|
||||
|
||||
if (options.columns.length === 0) {
|
||||
if (!isAdvanced && options.columns.length === 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text>{t("errors.noColumns")}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError && currentItems.length === 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="red">{t("errors.noCommunications")}</Text>
|
||||
</Center>
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<Center h="100%">
|
||||
<Text>{t("errors.noColumns")}</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -736,8 +751,9 @@ export default function DownloadClientsWidget({
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
||||
{showStats && (
|
||||
{(showStats || isAdvanced) && (
|
||||
<GlobalStatsBar
|
||||
key={displayMode}
|
||||
queueStats={queueStats}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
@@ -748,10 +764,13 @@ export default function DownloadClientsWidget({
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
|
||||
{queryIndicators}
|
||||
</Box>
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
cellPadding={`${size.cellPadding}px 8px`}
|
||||
fetching={isFetching && currentItems.length === 0}
|
||||
fetching={isFetching && (currentItems?.length ?? 0) === 0}
|
||||
fz={size.fontSize}
|
||||
records={sortedData}
|
||||
columns={effectiveColumns}
|
||||
@@ -780,22 +799,25 @@ export default function DownloadClientsWidget({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<WidgetFooter
|
||||
clients={clients}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
globalRatio={globalRatio}
|
||||
hasTorrents={hasTorrents}
|
||||
clientFilter={clientFilter}
|
||||
setClientFilter={setClientFilter}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
availableStatuses={availableStatuses}
|
||||
pauseQueue={mutatePauseQueue}
|
||||
resumeQueue={mutateResumeQueue}
|
||||
showStats={showStats}
|
||||
toggleStats={toggleStats}
|
||||
/>
|
||||
{(isAdvanced || height >= 96) && (
|
||||
<WidgetFooter
|
||||
key={displayMode}
|
||||
clients={clients}
|
||||
totalSpeed={totalSpeed}
|
||||
totalUpSpeed={totalUpSpeed}
|
||||
globalRatio={globalRatio}
|
||||
hasTorrents={hasTorrents}
|
||||
clientFilter={clientFilter}
|
||||
setClientFilter={setClientFilter}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
availableStatuses={availableStatuses}
|
||||
pauseQueue={mutatePauseQueue}
|
||||
resumeQueue={mutateResumeQueue}
|
||||
showStats={showStats}
|
||||
toggleStats={toggleStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
|
||||
</Stack>
|
||||
@@ -847,6 +869,7 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
|
||||
|
||||
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
|
||||
const t = useScopedI18n("widget.downloads");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const progressPercent = Math.floor(item.progress * 100);
|
||||
const categoryDisplay = formatCategoryDisplay(item.category);
|
||||
|
||||
@@ -909,7 +932,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
|
||||
<DetailPair label={t("items.sent.detailsTitle")} value={formatBytes(item.sent)} />
|
||||
)}
|
||||
{item.added !== undefined && (
|
||||
<DetailPair label={t("items.added.detailsTitle")} value={dayjs(item.added).format("YYYY-MM-DD HH:mm")} />
|
||||
<DetailPair label={t("items.added.detailsTitle")} value={formatLocalizedDateTime(item.added, locale)} />
|
||||
)}
|
||||
{categoryDisplay && <DetailPair label={t("items.category.detailsTitle")} value={categoryDisplay} />}
|
||||
<DetailPair label={t("items.id.detailsTitle")} value={item.id} />
|
||||
@@ -1046,7 +1069,7 @@ function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClos
|
||||
left={0}
|
||||
w="100vw"
|
||||
h="100vh"
|
||||
style={{ zIndex: 1000 }}
|
||||
style={{ zIndex: "var(--mantine-z-index-popover)" }}
|
||||
onClick={onClose}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
||||
|
||||
describe("downloads filters", () => {
|
||||
const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }];
|
||||
|
||||
test("keeps all eligible statuses available after applying a status filter", () => {
|
||||
expect(filterDownloadItemsByStatus(eligibleItems, ["downloading"])).toEqual([{ state: "downloading" }]);
|
||||
expect(getAvailableDownloadStates(eligibleItems)).toEqual(["downloading", "seeding"]);
|
||||
});
|
||||
|
||||
test("deduplicates available statuses", () => {
|
||||
expect(getAvailableDownloadStates([...eligibleItems, { state: "downloading" }])).toEqual([
|
||||
"downloading",
|
||||
"seeding",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloads column widths", () => {
|
||||
test("serializes numeric and pixel widths while ignoring automatic widths", () => {
|
||||
expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({
|
||||
name: 240,
|
||||
progress: 120,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
|
||||
|
||||
type DownloadState = ExtendedDownloadClientItem["state"];
|
||||
|
||||
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
|
||||
items: readonly T[],
|
||||
statusFilter: readonly string[],
|
||||
): T[] {
|
||||
if (statusFilter.length === 0) return [...items];
|
||||
return items.filter(({ state }) => statusFilter.includes(state));
|
||||
}
|
||||
|
||||
export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
|
||||
return [...new Set(items.map(({ state }) => state))];
|
||||
}
|
||||
|
||||
export function columnWidthsToRecord(columnsWidth: readonly Record<string, string | number>[]): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const entry of columnsWidth) {
|
||||
const key = Object.keys(entry)[0];
|
||||
if (!key) continue;
|
||||
const width = entry[key];
|
||||
if (typeof width === "number") result[key] = width;
|
||||
else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ const sortColumns = [
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
queryKey: [["widget", "downloads"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
filterStorageVolumes,
|
||||
normalizeStorageDeviceName,
|
||||
toScopedStorageVolumeValue,
|
||||
} from "./filter-storage-volumes";
|
||||
import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
|
||||
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
|
||||
|
||||
describe("normalizeStorageDeviceName", () => {
|
||||
|
||||
@@ -35,11 +35,7 @@ const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string
|
||||
);
|
||||
};
|
||||
|
||||
const matchesVisibleStorageVolume = (
|
||||
visibleVolume: string,
|
||||
integrationId: string,
|
||||
deviceName: string,
|
||||
): boolean => {
|
||||
const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
|
||||
const separatorIndex = visibleVolume.indexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return storageDeviceNamesMatch(visibleVolume, deviceName);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
|
||||
|
||||
describe("calculateBandwidth", () => {
|
||||
test("sorts samples before calculating a rate", () => {
|
||||
const result = calculateBandwidth([
|
||||
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 200 }] },
|
||||
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 300, transmit: 500 }] },
|
||||
]);
|
||||
|
||||
expect(result.data).toEqual([{ name: "wan", receive: 160, transmit: 240 }]);
|
||||
});
|
||||
|
||||
test("treats counter resets as zero instead of negative traffic", () => {
|
||||
const result = calculateBandwidth([
|
||||
{ timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 10, transmit: 10 }] },
|
||||
{ timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 100 }] },
|
||||
]);
|
||||
|
||||
expect(result.data).toEqual([{ name: "wan", receive: 0, transmit: 0 }]);
|
||||
});
|
||||
|
||||
test("rejects duplicate timestamps", () => {
|
||||
const timestamp = new Date("2026-01-01T00:00:00Z");
|
||||
expect(
|
||||
calculateBandwidth([
|
||||
{ timestamp, data: [{ name: "wan", receive: 100, transmit: 100 }] },
|
||||
{ timestamp, data: [{ name: "wan", receive: 50, transmit: 50 }] },
|
||||
]).data,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBitsPerSec", () => {
|
||||
test("handles invalid and negative rates", () => {
|
||||
expect(formatBitsPerSec(Number.NaN, 2)).toBe("0 b/s");
|
||||
expect(formatBitsPerSec(-1, 2)).toBe("0 b/s");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
|
||||
|
||||
export function formatBitsPerSec(bitsPerSecond: number, decimals: number): string {
|
||||
if (!Number.isFinite(bitsPerSecond) || bitsPerSecond <= 0) return "0 b/s";
|
||||
|
||||
const unitSize = 1024;
|
||||
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
|
||||
const unitIndex = Math.min(Math.floor(Math.log(bitsPerSecond) / Math.log(unitSize)), sizes.length - 1);
|
||||
|
||||
return `${parseFloat((bitsPerSecond / Math.pow(unitSize, unitIndex)).toFixed(decimals))} ${sizes[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
|
||||
const samples = data
|
||||
.filter((sample) => Number.isFinite(new Date(sample.timestamp).getTime()))
|
||||
.toSorted((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
|
||||
const latest = samples[0];
|
||||
const previous = samples[1];
|
||||
|
||||
if (!latest || !previous) return { data: [] };
|
||||
|
||||
const timeDiffInSeconds = (new Date(latest.timestamp).getTime() - new Date(previous.timestamp).getTime()) / 1000;
|
||||
if (timeDiffInSeconds <= 0) return { data: [] };
|
||||
|
||||
return {
|
||||
data: latest.data.flatMap((latestInterface) => {
|
||||
const previousInterface = previous.data.find((item) => item.name === latestInterface.name);
|
||||
if (!previousInterface) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
name: latestInterface.name,
|
||||
receive: Math.max(0, (8 * (latestInterface.receive - previousInterface.receive)) / timeDiffInSeconds),
|
||||
transmit: Math.max(0, (8 * (latestInterface.transmit - previousInterface.transmit)) / timeDiffInSeconds),
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
|
||||
import type { FirewallInterfacesSummary } from "@homarr/integrations";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
|
||||
import { FirewallMenu } from "./firewall-menu";
|
||||
import { FirewallVersion } from "./firewall-version";
|
||||
import { FirewallVersion, formatVersion } from "./firewall-version";
|
||||
|
||||
export interface Firewall {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
|
||||
const [selectedFirewall, setSelectedFirewall] = useState<string>("");
|
||||
export default function FirewallWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
itemId,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"firewall">) {
|
||||
const [selectedFirewall, setSelectedFirewall] = useState("");
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const t = useI18n();
|
||||
|
||||
const handleSelect = useCallback((value: string | null) => {
|
||||
if (value !== null) {
|
||||
setSelectedFirewall(value);
|
||||
} else {
|
||||
setSelectedFirewall("default_value");
|
||||
}
|
||||
setSelectedFirewall(value ?? "");
|
||||
}, []);
|
||||
|
||||
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
|
||||
@@ -40,202 +59,275 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
|
||||
const isTiny = width < 256;
|
||||
|
||||
const [accordionValue, setAccordionValue] = useLocalStorage<string | null>({
|
||||
key: `homarr-${itemId}-firewall`,
|
||||
defaultValue: "interfaces",
|
||||
});
|
||||
|
||||
const initialSelectedFirewall = firewallsVersionData[0]?.integration.id ?? "";
|
||||
const activeFirewall = firewallsVersionData.some(({ integration }) => integration.id === selectedFirewall)
|
||||
? selectedFirewall
|
||||
: initialSelectedFirewall;
|
||||
const displayedFirewallIds = isAdvanced
|
||||
? firewallsVersionData.map(({ integration }) => integration.id)
|
||||
: activeFirewall
|
||||
? [activeFirewall]
|
||||
: [];
|
||||
const dropdownItems = firewallsVersionData.map((firewall) => ({
|
||||
label: firewall.integration.name,
|
||||
value: firewall.integration.id,
|
||||
}));
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Group justify="space-between" w="100%" style={{ padding: "8px" }}>
|
||||
<FirewallMenu
|
||||
onChange={handleSelect}
|
||||
selectedFirewall={selectedFirewall || initialSelectedFirewall}
|
||||
dropdownItems={dropdownItems}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
<FirewallVersion
|
||||
firewallsVersionData={firewallsVersionData}
|
||||
selectedFirewall={selectedFirewall || initialSelectedFirewall}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
</Group>
|
||||
<Flex justify="center" align="center" wrap="wrap">
|
||||
{/* Render CPU and Memory data */}
|
||||
{firewallsCpuData
|
||||
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
|
||||
.map(({ summary, integration }) => (
|
||||
<RingProgress
|
||||
key={`${integration.name}-cpu`}
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{`${summary.total.toFixed(2)}%`}</Text>
|
||||
<IconCpu size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: Number(summary.total.toFixed(1)),
|
||||
color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green",
|
||||
},
|
||||
]}
|
||||
<Stack gap="xs" p={isAdvanced ? "xs" : 0}>
|
||||
{!isAdvanced && (
|
||||
<Group justify="space-between" w="100%" p="xs">
|
||||
<FirewallMenu
|
||||
onChange={handleSelect}
|
||||
selectedFirewall={activeFirewall}
|
||||
dropdownItems={dropdownItems}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
))}
|
||||
{firewallsMemoryData
|
||||
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
|
||||
.map(({ summary, integration }) => (
|
||||
<RingProgress
|
||||
key={`${integration.name}-memory`}
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{`${summary.percent.toFixed(1)}%`}</Text>
|
||||
<IconBrain size={isTiny ? 8 : 16} />
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: Number(summary.percent.toFixed(1)),
|
||||
color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green",
|
||||
},
|
||||
]}
|
||||
<FirewallVersion
|
||||
firewallsVersionData={firewallsVersionData}
|
||||
selectedFirewall={activeFirewall}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{firewallsInterfacesData
|
||||
.filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
|
||||
.map(({ summary }) => (
|
||||
<Accordion key="interfaces" value={accordionValue} onChange={setAccordionValue}>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
|
||||
<Text size={isTiny ? "8px" : "xs"}> {t("widget.firewall.widget.interfaces.title")} </Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Flex direction="column" key="interfaces">
|
||||
{Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? (
|
||||
calculateBandwidth(summary).data.map(({ name, receive, transmit }) => (
|
||||
<Flex
|
||||
key={name}
|
||||
direction={isTiny ? "column" : "row"}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: isTiny ? "2px" : "0px",
|
||||
}}
|
||||
>
|
||||
<Flex w={isTiny ? "100%" : "33%"} style={{ justifyContent: "flex-start" }}>
|
||||
<Text
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
color="lightblue"
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="4"
|
||||
w={isTiny ? "100%" : "33%"}
|
||||
style={{ justifyContent: "flex-start" }}
|
||||
>
|
||||
<IconArrowBarUp size={isTiny ? "8" : "12"} color="lightgreen" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="lightgreen" style={{ textAlign: "left" }}>
|
||||
{formatBitsPerSec(transmit, 2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="4"
|
||||
w={isTiny ? "100%" : "33%"}
|
||||
style={{ justifyContent: "flex-start" }}
|
||||
>
|
||||
<IconArrowBarDown size={isTiny ? "8" : "12"} color="yellow" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="yellow" style={{ textAlign: "left" }}>
|
||||
{formatBitsPerSec(receive, 2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))
|
||||
) : (
|
||||
<Box>No data available</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={isAdvanced && width >= 720 ? 2 : 1} spacing="xs">
|
||||
{displayedFirewallIds.map((firewallId) => {
|
||||
const cpu = firewallsCpuData.find(({ integration }) => integration.id === firewallId);
|
||||
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
|
||||
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
|
||||
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
|
||||
const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
|
||||
|
||||
return (
|
||||
<FirewallPanel
|
||||
key={firewallId}
|
||||
name={version?.integration.name ?? cpu?.integration.name ?? firewallId}
|
||||
kind={version?.integration.kind}
|
||||
version={version?.summary.version}
|
||||
cpu={cpu?.summary.total}
|
||||
memory={memory?.summary.percent}
|
||||
interfaces={interfaces?.summary}
|
||||
hasError={hasError}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
noDataLabel={t("widget.firewall.error.internalServerError")}
|
||||
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBitsPerSec(bytes: number, decimals: number): string {
|
||||
if (bytes === 0) return "0 b/s";
|
||||
|
||||
const kilobyte = 1024;
|
||||
const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(kilobyte));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
interface FirewallPanelProps {
|
||||
name: string;
|
||||
kind?: string;
|
||||
version?: string;
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
interfaces?: FirewallInterfacesSummary[];
|
||||
hasError: boolean;
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
noDataLabel: string;
|
||||
interfacesLabel: string;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
|
||||
const result = {
|
||||
data: [] as FirewallInterface[],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const FirewallPanel = ({
|
||||
name,
|
||||
kind,
|
||||
version,
|
||||
cpu,
|
||||
memory,
|
||||
interfaces,
|
||||
hasError,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
noDataLabel,
|
||||
interfacesLabel,
|
||||
t,
|
||||
}: FirewallPanelProps) => (
|
||||
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
|
||||
<Stack gap="xs">
|
||||
{isAdvanced && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="sm" fw={600} truncate="end">
|
||||
{name}
|
||||
</Text>
|
||||
{kind && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{kind}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{hasError && (
|
||||
<Tooltip label={noDataLabel}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Badge variant="outline" color="gray" size="xs">
|
||||
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
if (data.length > 1) {
|
||||
const firstData = data[0];
|
||||
const secondData = data[1];
|
||||
<Flex justify="center" align="center" wrap="wrap">
|
||||
{cpu !== undefined && (
|
||||
<MetricRing value={cpu} icon={IconCpu} isTiny={isTiny} label={t("widget.firewall.widget.cpu")} t={t} />
|
||||
)}
|
||||
{memory !== undefined && (
|
||||
<MetricRing
|
||||
value={memory}
|
||||
icon={IconBrain}
|
||||
isTiny={isTiny}
|
||||
label={t("widget.firewall.widget.memory")}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
if (firstData && secondData) {
|
||||
const time1 = new Date(firstData.timestamp);
|
||||
const time2 = new Date(secondData.timestamp);
|
||||
const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
|
||||
<InterfacesPanel
|
||||
summary={interfaces ?? []}
|
||||
isAdvanced={isAdvanced}
|
||||
isTiny={isTiny}
|
||||
accordionValue={accordionValue}
|
||||
setAccordionValue={setAccordionValue}
|
||||
noDataLabel={noDataLabel}
|
||||
label={interfacesLabel}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
firstData.data.forEach((iface) => {
|
||||
const ifaceName = iface.name;
|
||||
const recv1 = iface.receive;
|
||||
const trans1 = iface.transmit;
|
||||
|
||||
const iface2 = secondData.data.find((i) => i.name === ifaceName);
|
||||
|
||||
if (iface2) {
|
||||
const recv2 = iface2.receive;
|
||||
const trans2 = iface2.transmit;
|
||||
const recvDiff = recv1 - recv2;
|
||||
const transDiff = trans1 - trans2;
|
||||
|
||||
result.data.push({
|
||||
name: ifaceName,
|
||||
receive: (8 * recvDiff) / timeDiffInSeconds,
|
||||
transmit: (8 * transDiff) / timeDiffInSeconds,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
interface MetricRingProps {
|
||||
value: number;
|
||||
icon: typeof IconCpu;
|
||||
isTiny: boolean;
|
||||
label: string;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
|
||||
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
const status = getMetricStatus(safeValue);
|
||||
const statusLabel = t(`widget.firewall.status.${status}`);
|
||||
|
||||
return (
|
||||
<RingProgress
|
||||
aria-label={t("widget.firewall.metricAccessible", {
|
||||
metric: label,
|
||||
value: safeValue.toFixed(1),
|
||||
status: statusLabel,
|
||||
})}
|
||||
roundCaps
|
||||
size={isTiny ? 50 : 100}
|
||||
thickness={isTiny ? 4 : 8}
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
|
||||
<Icon size={isTiny ? 8 : 16} />
|
||||
{!isTiny && <Text size="8px">{statusLabel}</Text>}
|
||||
</Center>
|
||||
}
|
||||
sections={[
|
||||
{
|
||||
value: safeValue,
|
||||
color: status === "warning" ? "yellow" : status === "critical" ? "red" : "green",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
|
||||
if (value >= 75) return "critical";
|
||||
if (value > 50) return "warning";
|
||||
return "normal";
|
||||
};
|
||||
|
||||
interface InterfacesPanelProps {
|
||||
summary: FirewallInterfacesSummary[];
|
||||
isAdvanced: boolean;
|
||||
isTiny: boolean;
|
||||
accordionValue: string | null;
|
||||
setAccordionValue: (value: string | null) => void;
|
||||
noDataLabel: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const InterfacesPanel = ({
|
||||
summary,
|
||||
isAdvanced,
|
||||
isTiny,
|
||||
accordionValue,
|
||||
setAccordionValue,
|
||||
noDataLabel,
|
||||
label,
|
||||
}: InterfacesPanelProps) => {
|
||||
const bandwidth = calculateBandwidth(summary).data;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
value={isAdvanced ? "interfaces" : accordionValue}
|
||||
onChange={isAdvanced ? undefined : setAccordionValue}
|
||||
variant={isAdvanced ? "contained" : "default"}
|
||||
>
|
||||
<Accordion.Item value="interfaces">
|
||||
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
|
||||
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap={4}>
|
||||
{bandwidth.length > 0 ? (
|
||||
bandwidth.map(({ name, receive, transmit }) => (
|
||||
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
|
||||
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
|
||||
{name}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="green.3">
|
||||
{formatBitsPerSec(transmit, 2)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
|
||||
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
|
||||
{formatBitsPerSec(receive, 2)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
))
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{noDataLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge, Box } from "@mantine/core";
|
||||
|
||||
import type { FirewallVersionSummary } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FirewallVersionProps {
|
||||
firewallsVersionData: {
|
||||
@@ -18,24 +19,34 @@ export interface FirewallIntegration {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => (
|
||||
<Box>
|
||||
<Badge autoContrast variant="outline" color="lightgray" size={isTiny ? "8px" : "xs"} style={{ minHeight: "24px" }}>
|
||||
{firewallsVersionData
|
||||
.filter(({ integration }) => integration.id === selectedFirewall)
|
||||
.map(({ summary, integration }) => (
|
||||
<span key={integration.id}>{formatVersion(summary.version)}</span>
|
||||
))}
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
function formatVersion(inputString: string): string {
|
||||
return (
|
||||
<Box>
|
||||
<Badge
|
||||
autoContrast
|
||||
variant="outline"
|
||||
color="lightgray"
|
||||
size={isTiny ? "8px" : "xs"}
|
||||
style={{ minHeight: "24px" }}
|
||||
>
|
||||
{firewallsVersionData
|
||||
.filter(({ integration }) => integration.id === selectedFirewall)
|
||||
.map(({ summary, integration }) => (
|
||||
<span key={integration.id}>{formatVersion(summary.version, t("widget.firewall.versionUnknown"))}</span>
|
||||
))}
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export function formatVersion(inputString: string, fallback: string): string {
|
||||
const regex = /([\d._]+)/;
|
||||
const match = regex.exec(inputString);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
return "Unknown Version";
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
|
||||
icon: IconWall,
|
||||
queryKey: [["widget", "firewall"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(() => ({}));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getClusterAccordionDefault } from "./accordion-state";
|
||||
|
||||
describe("getClusterAccordionDefault", () => {
|
||||
test("opens only the first visible section in compact mode", () => {
|
||||
expect(getClusterAccordionDefault("compact", ["node", "qemu", "storage"])).toEqual(["node"]);
|
||||
});
|
||||
|
||||
test("opens every visible section in advanced mode", () => {
|
||||
expect(getClusterAccordionDefault("advanced", ["node", "qemu", "storage"])).toEqual(["node", "qemu", "storage"]);
|
||||
});
|
||||
|
||||
test("does not invent a section when none are visible", () => {
|
||||
expect(getClusterAccordionDefault("compact", [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const getClusterAccordionDefault = (
|
||||
displayMode: "compact" | "advanced" | undefined,
|
||||
visibleSections: readonly string[],
|
||||
): string[] => (displayMode === "advanced" ? [...visibleSections] : visibleSections.slice(0, 1));
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
|
||||
|
||||
@@ -8,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { formatUptime } from "../system-health";
|
||||
import { getClusterAccordionDefault } from "./accordion-state";
|
||||
import { ResourceAccordionItem } from "./resource-accordion-item";
|
||||
import { ResourceTable } from "./resource-table";
|
||||
|
||||
@@ -33,11 +35,16 @@ export const ClusterHealthMonitoring = ({
|
||||
integrationId,
|
||||
options,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
|
||||
const t = useI18n();
|
||||
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
|
||||
integrationId,
|
||||
});
|
||||
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
|
||||
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
|
||||
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
|
||||
const accordionValue = accordionValues[accordionScope] ?? accordionDefault;
|
||||
|
||||
if (!healthData) return <WidgetEmptyState />;
|
||||
|
||||
@@ -53,15 +60,13 @@ export const ClusterHealthMonitoring = ({
|
||||
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
|
||||
0,
|
||||
);
|
||||
const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
|
||||
const uptime = healthData.nodes.reduce((sum, { uptime: nodeUptime }) => (sum > nodeUptime ? sum : nodeUptime), 0);
|
||||
|
||||
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
|
||||
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
|
||||
const defaultValue = [options.visibleClusterSections.at(0) ?? "node"];
|
||||
|
||||
const isTiny = width < 256;
|
||||
const isTiny = displayMode !== "advanced" && width < 256;
|
||||
return (
|
||||
<Stack h="100%" p="xs" gap={isTiny ? "xs" : "md"}>
|
||||
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
|
||||
{options.showUptime && (
|
||||
<Group justify="center" wrap="nowrap">
|
||||
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
|
||||
@@ -81,7 +86,13 @@ export const ClusterHealthMonitoring = ({
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
{options.visibleClusterSections.length >= 1 && (
|
||||
<Accordion variant="contained" chevronPosition="right" multiple defaultValue={defaultValue}>
|
||||
<Accordion
|
||||
variant="contained"
|
||||
chevronPosition="right"
|
||||
multiple
|
||||
value={accordionValue}
|
||||
onChange={(value) => setAccordionValues((current) => ({ ...current, [accordionScope]: value }))}
|
||||
>
|
||||
{options.visibleClusterSections.includes("node") && (
|
||||
<ResourceAccordionItem
|
||||
value="node"
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea, Tabs } from "@mantine/core";
|
||||
import { Box, ScrollArea, SimpleGrid, Stack, Tabs } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
|
||||
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
|
||||
import { SystemHealthMonitoring } from "./system-health";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
|
||||
integration.kind === "proxmox" || integration.kind === "mock";
|
||||
|
||||
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
||||
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
|
||||
const t = useI18n();
|
||||
|
||||
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
|
||||
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
|
||||
|
||||
if (!clusterIntegrationId) {
|
||||
if (clusterIntegrationIds.length === 0) {
|
||||
return <SystemHealthMonitoring {...props} />;
|
||||
}
|
||||
|
||||
const otherIntegrationIds = integrations
|
||||
// We want to have the mock integration also in the system tab, so we use it for both
|
||||
.filter((integration) => integration.kind !== "proxmox")
|
||||
.map((integration) => integration.id);
|
||||
if (otherIntegrationIds.length === 0) {
|
||||
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
|
||||
const clusters = (
|
||||
<Stack gap="sm">
|
||||
{clusterIntegrationIds.map((integrationId) => (
|
||||
<ClusterHealthMonitoring key={integrationId} {...props} integrationId={integrationId} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (systemIntegrationIds.length === 0) {
|
||||
const onlyClusterIntegrationId = clusterIntegrationIds[0];
|
||||
if (clusterIntegrationIds.length === 1 && onlyClusterIntegrationId) {
|
||||
return <ClusterHealthMonitoring {...props} integrationId={onlyClusterIntegrationId} />;
|
||||
}
|
||||
return <ScrollArea h="100%">{clusters}</ScrollArea>;
|
||||
}
|
||||
|
||||
if (props.displayMode === "advanced") {
|
||||
return (
|
||||
<SimpleGrid cols={props.width >= 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
|
||||
<Box style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
|
||||
</Box>
|
||||
<ScrollArea h="100%">{clusters}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,11 +63,9 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="system">
|
||||
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="cluster">
|
||||
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
|
||||
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="cluster">{clusters}</Tabs.Panel>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
icon: IconHeartRateMonitor,
|
||||
queryKeys: [[["widget", "healthMonitoring"]], [["integration", "byIds"]]],
|
||||
supportsAdvancedFocus: true,
|
||||
queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
|
||||
|
||||
describe("partitionHealthMonitoringIntegrations", () => {
|
||||
test("preserves every Proxmox integration", () => {
|
||||
expect(
|
||||
partitionHealthMonitoringIntegrations([
|
||||
{ id: "proxmox-a", kind: "proxmox" },
|
||||
{ id: "proxmox-b", kind: "proxmox" },
|
||||
{ id: "system", kind: "glances" },
|
||||
]),
|
||||
).toEqual({
|
||||
clusterIntegrationIds: ["proxmox-a", "proxmox-b"],
|
||||
systemIntegrationIds: ["system"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps mock integrations in both views", () => {
|
||||
expect(partitionHealthMonitoringIntegrations([{ id: "mock", kind: "mock" }])).toEqual({
|
||||
clusterIntegrationIds: ["mock"],
|
||||
systemIntegrationIds: ["mock"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
interface HealthMonitoringIntegration {
|
||||
id: string;
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
|
||||
export const partitionHealthMonitoringIntegrations = (integrations: HealthMonitoringIntegration[]) => ({
|
||||
clusterIntegrationIds: integrations.filter(({ kind }) => kind === "proxmox" || kind === "mock").map(({ id }) => id),
|
||||
// Mock implements both contracts and remains visible in both views.
|
||||
systemIntegrationIds: integrations.filter(({ kind }) => kind !== "proxmox").map(({ id }) => id),
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -11,10 +13,10 @@ import {
|
||||
List,
|
||||
Modal,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconBrain,
|
||||
IconClock,
|
||||
@@ -30,6 +32,7 @@ import combineClasses from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
@@ -51,206 +54,222 @@ export const SystemHealthMonitoring = ({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"healthMonitoring">) => {
|
||||
const t = useI18n();
|
||||
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(null);
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const isTiny = width < 256;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
|
||||
if (healthData.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
||||
const filteredFileSystem = filterStorageVolumes(
|
||||
healthInfo.fileSystem,
|
||||
options.visibleStorageVolumes,
|
||||
integrationId,
|
||||
);
|
||||
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
|
||||
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
|
||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
||||
return (
|
||||
<Stack
|
||||
gap="sm"
|
||||
key={integrationId}
|
||||
h="100%"
|
||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||
p="sm"
|
||||
pos="relative"
|
||||
>
|
||||
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
|
||||
<Indicator
|
||||
className="health-monitoring-updates-reboot-indicator"
|
||||
inline
|
||||
processing
|
||||
styles={{ indicator: { pointerEvents: "none" } }}
|
||||
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
||||
position="top-end"
|
||||
size={16}
|
||||
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
||||
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
||||
>
|
||||
<ActionIcon
|
||||
className="health-monitoring-information-icon-avatar"
|
||||
variant={"light"}
|
||||
color="var(--mantine-color-text)"
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
<ScrollArea h="100%">
|
||||
<Stack mih="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
||||
const filteredFileSystem = filterStorageVolumes(
|
||||
healthInfo.fileSystem,
|
||||
options.visibleStorageVolumes,
|
||||
integrationId,
|
||||
);
|
||||
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
|
||||
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
|
||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
|
||||
return (
|
||||
<Stack
|
||||
gap="sm"
|
||||
key={integrationId}
|
||||
h={!isAdvanced && healthData.length === 1 ? "100%" : "auto"}
|
||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||
p="sm"
|
||||
pos="relative"
|
||||
>
|
||||
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
|
||||
<Indicator
|
||||
className="health-monitoring-updates-reboot-indicator"
|
||||
inline
|
||||
processing
|
||||
styles={{ indicator: { pointerEvents: "none" } }}
|
||||
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
||||
position="top-end"
|
||||
size={16}
|
||||
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
||||
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
||||
>
|
||||
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
size="auto"
|
||||
title={t("widget.healthMonitoring.popover.information")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="10px" className="health-monitoring-modal-stack">
|
||||
<Divider />
|
||||
<List className="health-monitoring-information-list" center spacing="xs">
|
||||
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
||||
memoryAvailable: memoryUsage.memFree.GB,
|
||||
percent: String(memoryUsage.memFree.percent),
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.version", {
|
||||
version: healthInfo.version,
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
|
||||
{formatUptime(healthInfo.uptime, t)}
|
||||
</List.Item>
|
||||
{healthInfo.loadAverage && (
|
||||
<>
|
||||
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
|
||||
{t("widget.healthMonitoring.popover.loadAverage")}
|
||||
</List.Item>
|
||||
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
|
||||
<List.Item className="health-monitoring-information-load-average-1min">
|
||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-load-average-5min">
|
||||
{t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "}
|
||||
{healthInfo.loadAverage["5min"]}%
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-load-average-15min">
|
||||
{t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
|
||||
{healthInfo.loadAverage["15min"]}%
|
||||
</List.Item>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
|
||||
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
|
||||
{options.cpu && (
|
||||
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
|
||||
)}
|
||||
{options.memory && (
|
||||
<MemoryRing
|
||||
available={healthInfo.memAvailableInBytes}
|
||||
used={healthInfo.memUsedInBytes}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
)}
|
||||
{options.gpu &&
|
||||
healthInfo.gpu.map((gpu) => (
|
||||
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
|
||||
))}
|
||||
</Flex>
|
||||
{options.fileSystem &&
|
||||
disksData.map((disk) => {
|
||||
return (
|
||||
<Card
|
||||
className={combineClasses(
|
||||
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
|
||||
classes.card,
|
||||
)}
|
||||
style={{ overflow: "visible" }}
|
||||
key={disk.deviceName}
|
||||
<ActionIcon
|
||||
className="health-monitoring-information-icon-avatar"
|
||||
variant={"light"}
|
||||
color="var(--mantine-color-text)"
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
p="xs"
|
||||
onClick={() => setOpenedIntegrationId(integrationId)}
|
||||
aria-label={t("widget.healthMonitoring.popover.information")}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
className="health-monitoring-disk-status"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
gap={8}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconServer className="health-monitoring-disk-icon" size="1rem" />
|
||||
<Text className="dihealth-monitoring-disk-name" size="xs">
|
||||
{disk.deviceName}
|
||||
</Text>
|
||||
</Group>
|
||||
{disk.temperature !== null && (
|
||||
<IconInfoCircle className="health-monitoring-information-icon" size={30} />
|
||||
</ActionIcon>
|
||||
</Indicator>
|
||||
<Modal
|
||||
opened={openedIntegrationId === integrationId}
|
||||
onClose={() => setOpenedIntegrationId(null)}
|
||||
size="auto"
|
||||
title={t("widget.healthMonitoring.popover.information")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="10px" className="health-monitoring-modal-stack">
|
||||
<Divider />
|
||||
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
|
||||
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
|
||||
{options.cpu && (
|
||||
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
|
||||
)}
|
||||
{options.memory && (
|
||||
<MemoryRing
|
||||
available={healthInfo.memAvailableInBytes}
|
||||
used={healthInfo.memUsedInBytes}
|
||||
isTiny={isTiny}
|
||||
/>
|
||||
)}
|
||||
{options.gpu &&
|
||||
healthInfo.gpu.map((gpu) => (
|
||||
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
|
||||
))}
|
||||
</Flex>
|
||||
{isAdvanced && (
|
||||
<Card radius={board.itemRadius} p="sm">
|
||||
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
|
||||
</Card>
|
||||
)}
|
||||
{options.fileSystem &&
|
||||
disksData.map((disk) => {
|
||||
return (
|
||||
<Card
|
||||
className={combineClasses(
|
||||
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
|
||||
classes.card,
|
||||
)}
|
||||
style={{ overflow: "visible" }}
|
||||
key={disk.deviceName}
|
||||
radius={board.itemRadius}
|
||||
p="xs"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
className="health-monitoring-disk-status"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
gap={8}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
|
||||
<Text className="health-monitoring-disk-temperature-value" size="xs">
|
||||
{options.fahrenheit
|
||||
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
||||
: `${disk.temperature}°C`}
|
||||
<IconServer className="health-monitoring-disk-icon" size="1rem" />
|
||||
<Text className="dihealth-monitoring-disk-name" size="xs">
|
||||
{disk.deviceName}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
|
||||
<Text className="health-monitoring-disk-status-value" size="xs">
|
||||
{disk.overallStatus ? disk.overallStatus : "N/A"}
|
||||
{disk.temperature !== null && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
|
||||
<Text className="health-monitoring-disk-temperature-value" size="xs">
|
||||
{options.fahrenheit
|
||||
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
||||
: `${disk.temperature}°C`}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
|
||||
<Text className="health-monitoring-disk-status-value" size="xs">
|
||||
{disk.overallStatus ? disk.overallStatus : "N/A"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} size="lg">
|
||||
<Progress.Section
|
||||
value={disk.percentage}
|
||||
color={progressColor(disk.percentage)}
|
||||
className="health-monitoring-disk-use-percentage"
|
||||
/>
|
||||
<Progress.Section
|
||||
className="health-monitoring-disk-available-percentage"
|
||||
value={100 - disk.percentage}
|
||||
color="default"
|
||||
/>
|
||||
</Progress.Root>
|
||||
<Group justify="space-between" gap={8} wrap="nowrap">
|
||||
<Text className="health-monitoring-disk-use-value" size="xs" c="dimmed">
|
||||
{t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
|
||||
</Text>
|
||||
<Text className="health-monitoring-disk-available-value" size="xs" c="dimmed">
|
||||
{formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} size="lg">
|
||||
<Progress.Section
|
||||
value={disk.percentage}
|
||||
color={progressColor(disk.percentage)}
|
||||
className="health-monitoring-disk-use-percentage"
|
||||
/>
|
||||
<Progress.Section
|
||||
className="health-monitoring-disk-available-percentage"
|
||||
value={100 - disk.percentage}
|
||||
color="default"
|
||||
/>
|
||||
</Progress.Root>
|
||||
<Group justify="space-between" gap={8} wrap="nowrap">
|
||||
<Text className="health-monitoring-disk-use-value" size="xs" c="dimmed">
|
||||
{t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
|
||||
</Text>
|
||||
<Text className="health-monitoring-disk-available-value" size="xs" c="dimmed">
|
||||
{formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
|
||||
|
||||
const SystemInformationList = ({
|
||||
healthInfo,
|
||||
memoryUsage,
|
||||
t,
|
||||
compact = false,
|
||||
}: {
|
||||
healthInfo: HealthInfo;
|
||||
memoryUsage: ReturnType<typeof formatMemoryUsage>;
|
||||
t: TranslationFunction;
|
||||
compact?: boolean;
|
||||
}) => (
|
||||
<List
|
||||
className="health-monitoring-information-list"
|
||||
center
|
||||
spacing={compact ? 4 : "xs"}
|
||||
size={compact ? "sm" : undefined}
|
||||
>
|
||||
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={compact ? 18 : 30} />}>
|
||||
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
|
||||
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={compact ? 18 : 30} />}>
|
||||
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
||||
memoryAvailable: memoryUsage.memFree.GB,
|
||||
percent: String(memoryUsage.memFree.percent),
|
||||
})}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={compact ? 18 : 30} />}>
|
||||
{t("widget.healthMonitoring.popover.version", { version: healthInfo.version })}
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={compact ? 18 : 30} />}>
|
||||
{formatUptime(healthInfo.uptime, t)}
|
||||
</List.Item>
|
||||
{healthInfo.loadAverage && (
|
||||
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={compact ? 18 : 30} />}>
|
||||
{t("widget.healthMonitoring.popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% /{" "}
|
||||
{healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
|
||||
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
|
||||
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
|
||||
const months = uptimeDuration.months();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
|
||||
|
||||
const permissions = {
|
||||
allowFullScreen: true,
|
||||
allowPayment: false,
|
||||
allowAutoPlay: true,
|
||||
allowMicrophone: false,
|
||||
allowCamera: false,
|
||||
allowGeolocation: false,
|
||||
allowModals: true,
|
||||
};
|
||||
|
||||
describe("iframe policy", () => {
|
||||
it("keeps sandbox-only modal permission out of Permissions Policy", () => {
|
||||
expect(getAllowedPermissions(permissions)).toBe("fullscreen *; autoplay *");
|
||||
expect(getSandboxFlags(permissions)).toContain("allow-modals");
|
||||
});
|
||||
|
||||
it("accepts only HTTP protocols", () => {
|
||||
expect(isSupportedProtocol("https://example.com")).toBe(true);
|
||||
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
|
||||
expect(isSupportedProtocol("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||
import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
@@ -9,7 +9,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
|
||||
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
|
||||
const t = useI18n();
|
||||
const { embedUrl, allowScrolling, ...permissions } = options;
|
||||
const allowedPermissions = getAllowedPermissions(permissions);
|
||||
@@ -21,25 +21,37 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100%" w="100%">
|
||||
<iframe
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={embedUrl}
|
||||
title="widget iframe"
|
||||
allow={allowedPermissions}
|
||||
scrolling={allowScrolling ? "yes" : "no"}
|
||||
sandbox={sandboxFlags.join(" ")}
|
||||
>
|
||||
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
|
||||
</iframe>
|
||||
</Box>
|
||||
<Stack h="100%" w="100%" gap={0}>
|
||||
{displayMode === "advanced" && (
|
||||
<Group key="advanced-header" px="xs" py={4} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{getFrameTitle(embedUrl)}
|
||||
</Text>
|
||||
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
|
||||
{embedUrl}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<Box key="frame" style={{ flex: 1, minHeight: 0 }}>
|
||||
<iframe
|
||||
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
|
||||
className={classes.iframe}
|
||||
src={embedUrl}
|
||||
title={getFrameTitle(embedUrl)}
|
||||
allow={allowedPermissions}
|
||||
scrolling={allowScrolling ? "yes" : "no"}
|
||||
sandbox={sandboxFlags.join(" ")}
|
||||
>
|
||||
<Text>{t("widget.iframe.error.noBrowerSupport")}</Text>
|
||||
</iframe>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const supportedProtocols = ["http", "https"];
|
||||
|
||||
const isSupportedProtocol = (url: string) => {
|
||||
export const isSupportedProtocol = (url: string) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
|
||||
@@ -74,19 +86,22 @@ const UnsupportedProtocol = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getAllowedPermissions = (
|
||||
export const getAllowedPermissions = (
|
||||
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
|
||||
) => {
|
||||
return (
|
||||
objectEntries(permissions)
|
||||
.filter(([_key, value]) => value)
|
||||
// * means it applies to all origins
|
||||
.map(([key]) => `${permissionMapping[key]} *`)
|
||||
// * means it applies to all origins. Sandbox-only flags such as
|
||||
// allow-modals intentionally have no Permissions Policy mapping.
|
||||
.flatMap(([key, value]) => {
|
||||
const permission = permissionMapping[key];
|
||||
return value && permission ? [`${permission} *`] : [];
|
||||
})
|
||||
.join("; ")
|
||||
);
|
||||
};
|
||||
|
||||
const getSandboxFlags = (
|
||||
export const getSandboxFlags = (
|
||||
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
|
||||
) => {
|
||||
const baseSandbox = [
|
||||
@@ -112,12 +127,21 @@ const getSandboxFlags = (
|
||||
return baseSandbox;
|
||||
};
|
||||
|
||||
const permissionMapping = {
|
||||
const permissionMapping: Partial<
|
||||
Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>
|
||||
> = {
|
||||
allowAutoPlay: "autoplay",
|
||||
allowCamera: "camera",
|
||||
allowFullScreen: "fullscreen",
|
||||
allowGeolocation: "geolocation",
|
||||
allowMicrophone: "microphone",
|
||||
allowPayment: "payment",
|
||||
allowModals: "allow-modals",
|
||||
} satisfies Record<keyof Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">, string>;
|
||||
};
|
||||
|
||||
const getFrameTitle = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "iframe";
|
||||
}
|
||||
};
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
|
||||
import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { useReducedMotion } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCalendar,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconPlayerPause,
|
||||
IconPlayerPlay,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
@@ -16,8 +24,16 @@ import { ALL_PHOTOS_ALBUM_ID } from "./constants";
|
||||
export default function ImmichAlbumCarouselWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
displayMode = "compact",
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"immich-albumCarousel">) {
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||
const persistedState = widgetStateRef?.current?.immichAlbumCarousel as
|
||||
| { currentPhotoIndex?: number; paused?: boolean }
|
||||
| undefined;
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(() =>
|
||||
typeof persistedState?.currentPhotoIndex === "number" ? persistedState.currentPhotoIndex : 0,
|
||||
);
|
||||
const [paused, setPaused] = useState(persistedState?.paused === true);
|
||||
|
||||
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
|
||||
{
|
||||
@@ -33,8 +49,42 @@ export default function ImmichAlbumCarouselWidget({
|
||||
}, [album?.assets, options.randomizePhotos]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPhotoIndex(0);
|
||||
}, [photoAssets]);
|
||||
if (photoAssets.length === 0) return;
|
||||
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
|
||||
}, [photoAssets.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
immichAlbumCarousel: { currentPhotoIndex, paused },
|
||||
};
|
||||
}, [currentPhotoIndex, paused, widgetStateRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
const clearActions = () => {
|
||||
if (!widgetStateRef.current) return;
|
||||
delete widgetStateRef.current.previousPhoto;
|
||||
delete widgetStateRef.current.nextPhoto;
|
||||
delete widgetStateRef.current.toggleSlideshow;
|
||||
};
|
||||
if (photoAssets.length <= 1) {
|
||||
clearActions();
|
||||
return;
|
||||
}
|
||||
const previousPhoto = () =>
|
||||
setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
|
||||
const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
|
||||
const toggleSlideshow = () => setPaused((value) => !value);
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
previousPhoto,
|
||||
nextPhoto,
|
||||
toggleSlideshow,
|
||||
};
|
||||
return clearActions;
|
||||
}, [photoAssets.length, widgetStateRef]);
|
||||
|
||||
if (!album) return <WidgetEmptyState />;
|
||||
|
||||
@@ -53,6 +103,10 @@ export default function ImmichAlbumCarouselWidget({
|
||||
setCurrentIndex={setCurrentPhotoIndex}
|
||||
rotationInterval={options.rotationIntervalSeconds}
|
||||
showPhotoInfo={options.showPhotoInfo}
|
||||
albumName={album.albumName}
|
||||
advanced={displayMode === "advanced"}
|
||||
paused={paused}
|
||||
setPaused={setPaused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +127,6 @@ function shuffle<T>(items: T[]) {
|
||||
interface CarouselProps {
|
||||
assets: {
|
||||
id: string;
|
||||
originalPath: string;
|
||||
fileModifiedAt: string;
|
||||
publicLink: string;
|
||||
}[];
|
||||
@@ -81,36 +134,126 @@ interface CarouselProps {
|
||||
setCurrentIndex: Dispatch<SetStateAction<number>>;
|
||||
rotationInterval: number;
|
||||
showPhotoInfo: boolean;
|
||||
albumName: string;
|
||||
advanced: boolean;
|
||||
paused: boolean;
|
||||
setPaused: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, showPhotoInfo }: CarouselProps) {
|
||||
function Carousel({
|
||||
assets,
|
||||
currentIndex,
|
||||
setCurrentIndex,
|
||||
rotationInterval,
|
||||
showPhotoInfo,
|
||||
albumName,
|
||||
advanced,
|
||||
paused,
|
||||
setPaused,
|
||||
}: CarouselProps) {
|
||||
const t = useScopedI18n("widget.immich-albumCarousel");
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || reduceMotion || assets.length <= 1) return;
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % assets.length);
|
||||
}, rotationInterval * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [assets.length, rotationInterval, setCurrentIndex]);
|
||||
}, [assets.length, paused, reduceMotion, rotationInterval, setCurrentIndex]);
|
||||
|
||||
const safeCurrentIndex = Math.min(currentIndex, assets.length - 1);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const currentAsset = assets[currentIndex]!;
|
||||
const currentAsset = assets[safeCurrentIndex]!;
|
||||
|
||||
const move = (offset: number) => setCurrentIndex((current) => (current + offset + assets.length) % assets.length);
|
||||
|
||||
return (
|
||||
<Box w="100%" h="100%" className={classes.carouselContainer}>
|
||||
<Image src={currentAsset.publicLink} alt="Album photo" className={classes.carouselImage} />
|
||||
<Stack w="100%" h="100%" gap={0}>
|
||||
<Box w="100%" style={{ flex: 1, minHeight: 0 }} className={classes.carouselContainer}>
|
||||
<Image src={currentAsset.publicLink} alt={t("albumPhoto")} className={classes.carouselImage} />
|
||||
|
||||
{showPhotoInfo && (
|
||||
<Stack gap="xs" className={classes.photoInfo} p="md">
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
|
||||
{advanced && (
|
||||
<>
|
||||
<ActionIcon
|
||||
aria-label={t("actions.previousPhoto")}
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
left="md"
|
||||
top="50%"
|
||||
onClick={() => move(-1)}
|
||||
>
|
||||
<IconChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
<Group pos="absolute" right="md" top="md" gap="xs">
|
||||
<ActionIcon
|
||||
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
onClick={() => setPaused((value) => !value)}
|
||||
>
|
||||
{paused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
aria-label={t("actions.nextPhoto")}
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
pos="absolute"
|
||||
right="md"
|
||||
top="50%"
|
||||
onClick={() => move(1)}
|
||||
>
|
||||
<IconChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(showPhotoInfo || advanced) && (
|
||||
<Stack gap="xs" className={classes.photoInfo} p="md">
|
||||
{albumName && (
|
||||
<Text size="xs" fw={600}>
|
||||
{albumName}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{safeCurrentIndex + 1} / {assets.length}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
{advanced && (
|
||||
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{assets.slice(0, 24).map((asset, index) => (
|
||||
<UnstyledButton
|
||||
key={asset.id}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
aria-label={t("actions.photo", { number: index + 1 })}
|
||||
aria-pressed={index === safeCurrentIndex}
|
||||
aria-current={index === safeCurrentIndex ? "true" : undefined}
|
||||
>
|
||||
<Image
|
||||
src={asset.publicLink}
|
||||
alt=""
|
||||
w={64}
|
||||
h={44}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
style={{ opacity: index === currentIndex ? 1 : 0.55 }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentIndex + 1} / {assets.length}
|
||||
</Text>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@tabler/icons-react";
|
||||
import z from "zod";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -14,6 +14,37 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
contextActions({ widgetStateRef }) {
|
||||
const call = (key: string) => () => {
|
||||
const action = widgetStateRef.current?.[key];
|
||||
if (typeof action === "function") action();
|
||||
};
|
||||
const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
|
||||
return [
|
||||
{
|
||||
key: "previousPhoto",
|
||||
label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
|
||||
icon: IconChevronLeft,
|
||||
disabled: disabled("previousPhoto"),
|
||||
onClick: call("previousPhoto"),
|
||||
},
|
||||
{
|
||||
key: "nextPhoto",
|
||||
label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
|
||||
icon: IconChevronRight,
|
||||
disabled: disabled("nextPhoto"),
|
||||
onClick: call("nextPhoto"),
|
||||
},
|
||||
{
|
||||
key: "toggleSlideshow",
|
||||
label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
|
||||
icon: IconPlayerPause,
|
||||
disabled: disabled("toggleSlideshow"),
|
||||
onClick: call("toggleSlideshow"),
|
||||
},
|
||||
];
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
albumId: factory.integrationSelect({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -15,16 +15,22 @@ import classes from "./component.module.css";
|
||||
export default function ImmichServerStatsWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
displayMode = "compact",
|
||||
width,
|
||||
}: WidgetComponentProps<"immich-serverStats">) {
|
||||
const t = useI18n();
|
||||
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
|
||||
);
|
||||
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%" p="md">
|
||||
const statsContent = (
|
||||
<SimpleGrid cols={displayMode === "advanced" ? 4 : width >= 320 ? 2 : 1} spacing="sm">
|
||||
{options.showUsers && (
|
||||
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
|
||||
)}
|
||||
@@ -49,6 +55,39 @@ export default function ImmichServerStatsWidget({
|
||||
value={formatBytes(stats.totalLibraryUsageInBytes)}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
if (displayMode === "compact") {
|
||||
return (
|
||||
<Stack gap="md" h="100%" p="md" justify="center">
|
||||
{statsContent}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
|
||||
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
|
||||
return (
|
||||
<Stack gap="lg" h="100%" p="lg">
|
||||
{statsContent}
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
|
||||
{sortedAlbums.map((album) => (
|
||||
<Stack key={album.id} gap={4} p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{album.albumName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{album.assetCount.toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["immich"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showUsers: factory.switch({
|
||||
|
||||
@@ -72,6 +72,7 @@ export type {
|
||||
WidgetContextActionProps,
|
||||
WidgetOptionsSettings,
|
||||
} from "./definition";
|
||||
export { getWidgetQueryKeys } from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Anchor, Button, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActionIcon, Anchor, Badge, Button, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
@@ -16,6 +17,9 @@ export default function IndexerManagerWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
isEditMode,
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"indexerManager">) {
|
||||
const t = useI18n();
|
||||
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
|
||||
@@ -25,8 +29,22 @@ export default function IndexerManagerWidget({
|
||||
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
|
||||
});
|
||||
const board = useRequiredBoard();
|
||||
const hasSmallWidth = width < 256;
|
||||
const hasSmallHeight = height < 256;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const hasSmallWidth = !isAdvanced && width < 256;
|
||||
const hasSmallHeight = !isAdvanced && height < 256;
|
||||
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
|
||||
const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
testAllIndexers: () => testAll({ integrationIds }),
|
||||
};
|
||||
return () => {
|
||||
if (widgetStateRef.current) delete widgetStateRef.current.testAllIndexers;
|
||||
};
|
||||
}, [integrationIds, testAll, widgetStateRef]);
|
||||
|
||||
return (
|
||||
<Flex className="indexer-manager-container" h="100%" direction="column" gap="sm" p="sm" align="center">
|
||||
@@ -39,6 +57,18 @@ export default function IndexerManagerWidget({
|
||||
<Text size={hasSmallWidth ? "xs" : "md"} fw="bold">
|
||||
{t("widget.indexerManager.title")}
|
||||
</Text>
|
||||
{isAdvanced && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{allIndexers.length - unavailableCount}
|
||||
</Badge>
|
||||
{unavailableCount > 0 && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{unavailableCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{hasSmallHeight && (
|
||||
<ActionIcon
|
||||
className="indexer-manager-test-action-icon"
|
||||
@@ -46,6 +76,7 @@ export default function IndexerManagerWidget({
|
||||
radius={board.itemRadius}
|
||||
variant="light"
|
||||
loading={isPending}
|
||||
disabled={isEditMode}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAll({ integrationIds });
|
||||
@@ -63,8 +94,22 @@ export default function IndexerManagerWidget({
|
||||
flex={1}
|
||||
>
|
||||
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
|
||||
{indexersData.map(({ integrationId, indexers }) => (
|
||||
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
|
||||
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
||||
{(isAdvanced || indexersData.length > 1) && (
|
||||
<Group justify="space-between" wrap="nowrap" py={4}>
|
||||
<Text size="xs" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
{error && (
|
||||
<Tooltip label={error} multiline maw={360}>
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{indexers.map((indexer) => (
|
||||
<Group
|
||||
className={`indexer-manager-line indexer-manager-${indexer.name}`}
|
||||
@@ -82,6 +127,11 @@ export default function IndexerManagerWidget({
|
||||
{indexer.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{isAdvanced && (
|
||||
<Text size="xs" c="dimmed" truncate="end" style={{ flex: 1 }}>
|
||||
{indexer.url}
|
||||
</Text>
|
||||
)}
|
||||
{indexer.status === false || indexer.enabled === false ? (
|
||||
<IconCircleX
|
||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||
@@ -110,6 +160,7 @@ export default function IndexerManagerWidget({
|
||||
variant="light"
|
||||
leftSection={<IconTestPipe size={"1rem"} />}
|
||||
loading={isPending}
|
||||
disabled={isEditMode}
|
||||
loaderProps={{ type: "dots" }}
|
||||
onClick={() => {
|
||||
testAll({ integrationIds });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconReportSearch, IconServerOff } from "@tabler/icons-react";
|
||||
import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
@@ -7,6 +7,22 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
|
||||
icon: IconReportSearch,
|
||||
queryKey: [["widget", "indexerManager"]],
|
||||
contextActions: ({ widgetStateRef, context }) => [
|
||||
{
|
||||
key: "test-all-indexers",
|
||||
label: (t) => t("widget.indexerManager.testAll"),
|
||||
icon: IconTestPipe,
|
||||
disabled:
|
||||
context.isEditMode ||
|
||||
!context.canInteractWithSelectedIntegrations ||
|
||||
typeof widgetStateRef.current?.testAllIndexers !== "function",
|
||||
onClick: () => {
|
||||
const action = widgetStateRef.current?.testAllIndexers;
|
||||
if (typeof action === "function") action();
|
||||
},
|
||||
},
|
||||
],
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -12,14 +28,29 @@ import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
import type { MediaMissingTab } from "./tabs";
|
||||
import { resolveMediaMissingTab } from "./tabs";
|
||||
|
||||
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
|
||||
export default function MediaMissingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const pageSize = Number(options.pageSize);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
||||
{ integrationIds, pageSize },
|
||||
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
|
||||
);
|
||||
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
|
||||
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
|
||||
}, [activeTab, selectedTab]);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
if (data.length === 0) throw new NoIntegrationDataError();
|
||||
@@ -32,25 +63,26 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
|
||||
</Center>
|
||||
);
|
||||
|
||||
const missing = data.flatMap((entry) =>
|
||||
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const queued = data.flatMap((entry) =>
|
||||
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
|
||||
|
||||
const isThin = width > 0 && width < 160;
|
||||
const isShort = height > 0 && height < 180;
|
||||
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
|
||||
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
|
||||
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
|
||||
const isShort = !isAdvanced && height > 0 && height < 180;
|
||||
const targetCardWidth = isShort ? 130 : 200;
|
||||
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
|
||||
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
|
||||
const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
|
||||
const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
|
||||
|
||||
const tabLabel = (label: string, shown: number, total: number) =>
|
||||
isThin ? total : `${label} (${shown}/${total})`;
|
||||
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
|
||||
|
||||
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
|
||||
const renderPanel = (
|
||||
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
|
||||
emptyLabel: string,
|
||||
) => (
|
||||
<ScrollArea h="100%" scrollbarSize={4}>
|
||||
<Box p="xs">
|
||||
{entries.length === 0 ? (
|
||||
@@ -68,12 +100,60 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
const partialFailures = failedIntegrations.length > 0 && (
|
||||
<Group gap={4} p={4} wrap="wrap">
|
||||
{failedIntegrations.map((entry) => (
|
||||
<Tooltip key={entry.integrationId} label={entry.error} multiline maw={360}>
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{entry.integrationName}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
{partialFailures}
|
||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{options.showMissing && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconQuestionMark size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
|
||||
</Paper>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<Group p="xs" gap="xs">
|
||||
<IconDownload size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box h="calc(100% - 40px)">{renderPanel(queued, t("empty.queued"))}</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={options.showMissing ? "missing" : "queued"}
|
||||
value={activeTab}
|
||||
onChange={(value) => {
|
||||
if (value === "missing" || value === "queued") setSelectedTab(value);
|
||||
}}
|
||||
h="100%"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{partialFailures}
|
||||
<Tabs.List grow>
|
||||
{options.showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
@@ -116,7 +196,14 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
|
||||
<ThemeIcon
|
||||
className={classes.poster}
|
||||
h={size}
|
||||
w={w}
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={type === "movie" ? "yellow" : "blue"}
|
||||
>
|
||||
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
@@ -183,8 +270,25 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
|
||||
);
|
||||
};
|
||||
|
||||
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
|
||||
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
|
||||
const CardShell = ({
|
||||
item,
|
||||
density,
|
||||
children,
|
||||
}: {
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component="a"
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
radius="sm"
|
||||
p="xs"
|
||||
h={CARD_HEIGHT[density]}
|
||||
>
|
||||
{item.imageUrl && (
|
||||
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMediaMissingTab } from "./tabs";
|
||||
|
||||
describe("resolveMediaMissingTab", () => {
|
||||
test("preserves the selected tab while it remains enabled", () => {
|
||||
expect(resolveMediaMissingTab("queued", true, true)).toBe("queued");
|
||||
});
|
||||
|
||||
test("falls back when the selected tab is disabled", () => {
|
||||
expect(resolveMediaMissingTab("missing", false, true)).toBe("queued");
|
||||
expect(resolveMediaMissingTab("queued", true, false)).toBe("missing");
|
||||
});
|
||||
|
||||
test("returns no tab when both panels are disabled", () => {
|
||||
expect(resolveMediaMissingTab("missing", false, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export type MediaMissingTab = "missing" | "queued";
|
||||
|
||||
export const resolveMediaMissingTab = (
|
||||
current: MediaMissingTab,
|
||||
showMissing: boolean,
|
||||
showQueued: boolean,
|
||||
): MediaMissingTab | null => {
|
||||
if (current === "missing" && showMissing) return current;
|
||||
if (current === "queued" && showQueued) return current;
|
||||
if (showMissing) return "missing";
|
||||
if (showQueued) return "queued";
|
||||
return null;
|
||||
};
|
||||
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { Avatar, Badge, Box, Divider, Group, Image, Stack, Text, TooltipFloating, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Image,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TooltipFloating,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -17,26 +30,36 @@ import { OverflowBadge } from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
|
||||
export default function MediaReleasesWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaReleases">) {
|
||||
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} />
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
|
||||
{releases.map((item, index) => (
|
||||
<Fragment key={`${item.integration.id}:${item.id}`}>
|
||||
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
|
||||
<Item item={item} options={options} isAdvanced={isAdvanced} />
|
||||
</Fragment>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
|
||||
options: WidgetComponentProps<"mediaReleases">["options"];
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
const formatReleaseDate = (value: unknown, locale: string) => {
|
||||
@@ -52,7 +75,7 @@ const formatReleaseDate = (value: unknown, locale: string) => {
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const Item = ({ item, options }: ItemProps) => {
|
||||
const Item = ({ item, options, isAdvanced }: ItemProps) => {
|
||||
const locale = useCurrentLocale();
|
||||
const t = useI18n();
|
||||
const length = formatLength(item.length, item.type, t);
|
||||
@@ -62,7 +85,12 @@ const Item = ({ item, options }: ItemProps) => {
|
||||
label={item.description}
|
||||
w={300}
|
||||
multiline
|
||||
disabled={item.description === undefined || item.description.trim() === "" || !options.showDescriptionTooltip}
|
||||
disabled={
|
||||
isAdvanced ||
|
||||
item.description === undefined ||
|
||||
item.description.trim() === "" ||
|
||||
!options.showDescriptionTooltip
|
||||
}
|
||||
>
|
||||
<UnstyledButton
|
||||
component="a"
|
||||
@@ -70,9 +98,18 @@ const Item = ({ item, options }: ItemProps) => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
pos="relative"
|
||||
p={options.layout === "poster" ? 0 : 4}
|
||||
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
|
||||
h="100%"
|
||||
style={
|
||||
isAdvanced
|
||||
? {
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{options.layout === "backdrop" && (
|
||||
{!isAdvanced && options.layout === "backdrop" && (
|
||||
<Box
|
||||
w="100%"
|
||||
h="100%"
|
||||
@@ -91,7 +128,9 @@ const Item = ({ item, options }: ItemProps) => {
|
||||
)}
|
||||
<Group justify="space-between" h="100%" wrap="nowrap">
|
||||
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
|
||||
{options.layout === "poster" && <Image w={60} src={item.imageUrls.poster} alt={item.title} />}
|
||||
{(isAdvanced || options.layout === "poster") && (
|
||||
<Image w={isAdvanced ? 80 : 60} src={item.imageUrls.poster} alt={item.title} radius="xs" />
|
||||
)}
|
||||
<Stack gap={4}>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw="bold" lineClamp={2}>
|
||||
@@ -136,15 +175,20 @@ const Item = ({ item, options }: ItemProps) => {
|
||||
groupGap={4}
|
||||
data={item.tags}
|
||||
overflowCount={3}
|
||||
disablePopover
|
||||
disablePopover={!isAdvanced}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
)}
|
||||
{isAdvanced && item.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
{(options.showType || options.showSource) && (
|
||||
{(isAdvanced || options.showType || options.showSource) && (
|
||||
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
|
||||
{options.showType && (
|
||||
{(isAdvanced || options.showType) && (
|
||||
<Badge
|
||||
w="max-content"
|
||||
size="xs"
|
||||
@@ -155,7 +199,7 @@ const Item = ({ item, options }: ItemProps) => {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{options.showSource && (
|
||||
{(isAdvanced || options.showSource) && (
|
||||
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { toValidDate } from "@homarr/common";
|
||||
import type { MediaRequestStatus } from "@homarr/integrations/types";
|
||||
@@ -35,7 +36,14 @@ export default function MediaServerWidget({
|
||||
isEditMode,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaRequests-requestList">) {
|
||||
const interactIntegrationIds = new Set(
|
||||
useIntegrationsWithInteractAccess()
|
||||
.filter(({ id }) => integrationIds.includes(id))
|
||||
.map(({ id }) => id),
|
||||
);
|
||||
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
integrationIds,
|
||||
statuses:
|
||||
@@ -61,7 +69,9 @@ export default function MediaServerWidget({
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
@@ -92,10 +102,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
canInteract: boolean;
|
||||
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
|
||||
}
|
||||
|
||||
const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) => {
|
||||
const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
|
||||
@@ -152,6 +164,11 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
|
||||
{t(`availability.${request.availability}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isAdvanced && (
|
||||
<Badge size="xs" variant="outline">
|
||||
{request.integration.name}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap">
|
||||
<Avatar
|
||||
@@ -186,7 +203,11 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
|
||||
{request.name || "unknown"}
|
||||
</Anchor>
|
||||
{request.status === "pending" ? (
|
||||
<DecisionButtons requestId={request.id} integrationId={request.integrationId} />
|
||||
<DecisionButtons
|
||||
requestId={request.id}
|
||||
integrationId={request.integrationId}
|
||||
canInteract={canInteract}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={request.status} />
|
||||
)}
|
||||
@@ -201,15 +222,21 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
|
||||
interface DecisionButtonsProps {
|
||||
requestId: number;
|
||||
integrationId: string;
|
||||
canInteract: boolean;
|
||||
}
|
||||
|
||||
const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => {
|
||||
const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButtonsProps) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation({
|
||||
const {
|
||||
mutate: mutateRequestAnswer,
|
||||
isPending,
|
||||
error,
|
||||
} = clientApi.widget.mediaRequests.answerRequest.useMutation({
|
||||
onSettled: () => void utils.widget.mediaRequests.invalidate(),
|
||||
});
|
||||
const t = useScopedI18n("widget.mediaRequests-requestList");
|
||||
const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => {
|
||||
if (!canInteract || isPending) return;
|
||||
mutateRequestAnswer({
|
||||
integrationId,
|
||||
requestId,
|
||||
@@ -218,13 +245,15 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className="mediaRequests-list-item-pending-buttons" gap="sm">
|
||||
<Group className="mediaRequests-list-item-pending-buttons" gap="sm" title={error?.message}>
|
||||
<Tooltip label={t("pending.approve")}>
|
||||
<ActionIcon
|
||||
className="mediaRequests-list-item-pending-button-approve"
|
||||
variant="light"
|
||||
color="green"
|
||||
size="xs"
|
||||
disabled={!canInteract || isPending}
|
||||
aria-label={t("pending.approve")}
|
||||
onClick={() => {
|
||||
handleDecision("approve");
|
||||
}}
|
||||
@@ -238,6 +267,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
disabled={!canInteract || isPending}
|
||||
aria-label={t("pending.decline")}
|
||||
onClick={() => {
|
||||
handleDecision("decline");
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Card, Grid, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceTv,
|
||||
@@ -34,6 +34,8 @@ export default function MediaServerWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaRequests-requestStats">) {
|
||||
const t = useScopedI18n("widget.mediaRequests-requestStats");
|
||||
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
|
||||
@@ -88,7 +90,12 @@ export default function MediaServerWidget({
|
||||
},
|
||||
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
|
||||
|
||||
const isTiny = width < 256;
|
||||
const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
|
||||
const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
|
||||
const visibleUsers = requestStats.users.slice(
|
||||
0,
|
||||
displayMode === "advanced" ? 50 : Math.max(1, Math.floor(height / 44) - 3),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
@@ -107,7 +114,7 @@ export default function MediaServerWidget({
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
{visibleStats.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
@@ -131,44 +138,61 @@ export default function MediaServerWidget({
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} w="100%">
|
||||
<Stack gap={4}>
|
||||
{visibleUsers.map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={`${user.integration.id}:${user.id}`}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group
|
||||
className="mediaRequests-stats-users-user-group"
|
||||
h="100%"
|
||||
p={0}
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
{displayMode === "advanced" && (
|
||||
<Group justify="center" gap={4}>
|
||||
{requestStats.integrations.map((integration) => (
|
||||
<Badge key={integration.id} size="xs" variant="light">
|
||||
{integration.name}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Divider,
|
||||
Group,
|
||||
Popover,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconDeviceTv,
|
||||
IconHeadphones,
|
||||
@@ -17,14 +28,17 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, integrationDefs } from "@homarr/definitions";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
|
||||
|
||||
@@ -53,135 +67,117 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
|
||||
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
|
||||
}
|
||||
|
||||
export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
|
||||
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
|
||||
export default function MediaServerWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
isEditMode,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaServer">) {
|
||||
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
|
||||
integrationIds,
|
||||
showOnlyPlaying: options.showOnlyPlaying,
|
||||
});
|
||||
const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery);
|
||||
const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]);
|
||||
|
||||
const t = useScopedI18n("widget.mediaServer");
|
||||
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "user.username",
|
||||
header: t("items.user"),
|
||||
size: 160,
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const showLocation = isAdvanced || options.showLocation;
|
||||
const showBitrate = isAdvanced || options.showBitrate;
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
|
||||
const result: MRT_ColumnDef<StreamSession>[] = [
|
||||
...(width >= 300 || isAdvanced
|
||||
? [
|
||||
{
|
||||
accessorKey: "user.username",
|
||||
header: t("items.user"),
|
||||
size: 160,
|
||||
|
||||
Cell: ({ row }) => (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar size={28} src={row.original.user.profilePictureUrl} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{row.original.user.username}
|
||||
</Text>
|
||||
<Text
|
||||
size="10px"
|
||||
c="dimmed"
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{row.original.sessionName}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
Cell: ({ row }) => (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Avatar size={28} src={row.original.user?.profilePictureUrl} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{row.original.user?.username ?? t("items.unknownUser")}
|
||||
</Text>
|
||||
<Text
|
||||
size="10px"
|
||||
c="dimmed"
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{row.original.sessionName}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
),
|
||||
} satisfies MRT_ColumnDef<StreamSession>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
|
||||
header: t("items.currentlyPlaying"),
|
||||
|
||||
Cell: ({ row }) => {
|
||||
const currentlyPlaying = row.original.currentlyPlaying;
|
||||
if (!currentlyPlaying) return null;
|
||||
|
||||
const playback = currentlyPlaying.playback;
|
||||
const isPaused = playback?.state === "paused";
|
||||
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
|
||||
|
||||
const positionMs = playback?.positionMs ?? null;
|
||||
const durationMs = playback?.durationMs ?? null;
|
||||
const progressPercent =
|
||||
positionMs !== null && durationMs !== null && durationMs > 0
|
||||
? Math.min(100, Math.round((positionMs / durationMs) * 100))
|
||||
: null;
|
||||
const remainingMinutes =
|
||||
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
|
||||
|
||||
return (
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Icon
|
||||
size={16}
|
||||
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{currentlyPlaying.name}
|
||||
</Text>
|
||||
{isPaused && (
|
||||
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
|
||||
{t("items.paused")}
|
||||
</Text>
|
||||
)}
|
||||
{!isPaused && remainingMinutes !== null && (
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{t("items.remaining", { minutes: remainingMinutes.toString() })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{progressPercent !== null && (
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
size={4}
|
||||
color={isPaused ? "yellow" : "green"}
|
||||
style={{ backgroundColor: "rgba(255, 255, 255, 0.15)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<SessionDetailsPopover
|
||||
item={row.original}
|
||||
opened={selectedRowId === row.id}
|
||||
onChange={(opened) => setSelectedRowId(opened ? row.id : null)}
|
||||
>
|
||||
<CurrentlyPlaying item={row.original} />
|
||||
</SessionDetailsPopover>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: t("items.status"),
|
||||
size: 110,
|
||||
} satisfies MRT_ColumnDef<StreamSession>,
|
||||
...(width >= 420 || isAdvanced
|
||||
? [
|
||||
{
|
||||
id: "status",
|
||||
header: t("items.status"),
|
||||
size: 110,
|
||||
|
||||
Cell: ({ row }) => {
|
||||
const currentlyPlaying = row.original.currentlyPlaying;
|
||||
if (!currentlyPlaying) return null;
|
||||
Cell: ({ row }) => {
|
||||
const currentlyPlaying = row.original.currentlyPlaying;
|
||||
if (!currentlyPlaying) return null;
|
||||
|
||||
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
|
||||
const bitrateLabel = formatBitrate(currentlyPlaying.metadata?.bitrateKbps);
|
||||
const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding);
|
||||
const location = showLocation ? currentlyPlaying.location : null;
|
||||
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null;
|
||||
|
||||
return (
|
||||
<Stack gap={4} align="flex-start">
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{(currentlyPlaying.location ?? bitrateLabel) && (
|
||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||
<Group gap={4} align="center">
|
||||
{currentlyPlaying.location &&
|
||||
(currentlyPlaying.location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
|
||||
{currentlyPlaying.location && (
|
||||
<Text size="10px" c="dimmed" tt="uppercase">
|
||||
{currentlyPlaying.location}
|
||||
</Text>
|
||||
return (
|
||||
<Stack gap={4} align="flex-start">
|
||||
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
|
||||
{t(`items.${status}` as never)}
|
||||
</Badge>
|
||||
{(location ?? bitrateLabel) && (
|
||||
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
|
||||
<Group gap={4} align="center">
|
||||
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
|
||||
{location && (
|
||||
<Text size="10px" c="dimmed" tt="uppercase">
|
||||
{location}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="10px" c="dimmed">
|
||||
{bitrateLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="10px" c="dimmed">
|
||||
{bitrateLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
} satisfies MRT_ColumnDef<StreamSession>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return result;
|
||||
}, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]);
|
||||
|
||||
// Only render the flat list of sessions when the currentStreams change
|
||||
// Otherwise it will always create a new array reference and cause the table to re-render
|
||||
@@ -190,37 +186,34 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
||||
currentStreams.flatMap((pair) =>
|
||||
pair.sessions.map((session) => ({
|
||||
...session,
|
||||
integrationId: pair.integrationId,
|
||||
integrationKind: pair.integrationKind,
|
||||
integrationName: integrationDefs[pair.integrationKind].name,
|
||||
integrationName: pair.integrationName,
|
||||
integrationIcon: getIconUrl(pair.integrationKind),
|
||||
})),
|
||||
),
|
||||
[currentStreams],
|
||||
);
|
||||
|
||||
const { openModal } = useModalAction(ItemInfoModal);
|
||||
const table = useTranslatedMantineReactTable({
|
||||
columns,
|
||||
data: flatSessions,
|
||||
enablePagination: false,
|
||||
enableTopToolbar: false,
|
||||
enableTopToolbar: isAdvanced,
|
||||
enableBottomToolbar: false,
|
||||
enableSorting: false,
|
||||
enableSorting: isAdvanced,
|
||||
enableColumnActions: false,
|
||||
enableStickyHeader: false,
|
||||
enableColumnOrdering: false,
|
||||
enableRowSelection: false,
|
||||
enableFullScreenToggle: false,
|
||||
enableGlobalFilter: false,
|
||||
enableGlobalFilter: isAdvanced,
|
||||
enableDensityToggle: false,
|
||||
enableFilters: false,
|
||||
enableHiding: false,
|
||||
enableColumnPinning: true,
|
||||
enableColumnPinning: false,
|
||||
initialState: {
|
||||
density: "xs",
|
||||
columnPinning: {
|
||||
right: ["currentlyPlaying", "status"],
|
||||
},
|
||||
},
|
||||
mantineTableHeadProps: {
|
||||
fz: "xs",
|
||||
@@ -232,6 +225,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
||||
flex: 1,
|
||||
withBorder: false,
|
||||
shadow: undefined,
|
||||
className: classes.tablePaper,
|
||||
},
|
||||
mantineTableProps: {
|
||||
className: "media-server-widget-table",
|
||||
@@ -240,21 +234,32 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
||||
},
|
||||
},
|
||||
mantineTableContainerProps: {
|
||||
className: classes.tableContainer,
|
||||
style: {
|
||||
height: "100%",
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
},
|
||||
},
|
||||
mantineTableBodyCellProps: ({ row }) => ({
|
||||
onClick: () => {
|
||||
openModal(
|
||||
{
|
||||
item: row.original,
|
||||
},
|
||||
{
|
||||
title: row.original.sessionName,
|
||||
},
|
||||
);
|
||||
},
|
||||
mantineTableBodyRowProps: ({ row }) => {
|
||||
const toggleDetails = () => setSelectedRowId((current) => (current === row.id ? null : row.id));
|
||||
|
||||
return {
|
||||
className: isEditMode ? undefined : classes.sessionRow,
|
||||
tabIndex: isEditMode ? -1 : 0,
|
||||
"aria-label": row.original.sessionName,
|
||||
"aria-haspopup": isEditMode ? undefined : "dialog",
|
||||
"aria-expanded": isEditMode ? undefined : selectedRowId === row.id,
|
||||
onClick: isEditMode ? undefined : toggleDetails,
|
||||
onKeyDown: isEditMode
|
||||
? undefined
|
||||
: (event) => {
|
||||
if (event.currentTarget !== event.target) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
toggleDetails();
|
||||
},
|
||||
};
|
||||
},
|
||||
mantineTableBodyCellProps: () => ({
|
||||
py: 4,
|
||||
style: {
|
||||
overflowX: "hidden",
|
||||
@@ -263,145 +268,279 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
|
||||
}),
|
||||
});
|
||||
|
||||
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
|
||||
const session = flatSessions.find((session) => session.integrationKind === kind);
|
||||
return {
|
||||
integrationKind: kind,
|
||||
integrationIcon: session?.integrationIcon,
|
||||
integrationName: session?.integrationName,
|
||||
};
|
||||
});
|
||||
const uniqueIntegrations = currentStreams.map((stream) => ({
|
||||
integrationId: stream.integrationId,
|
||||
integrationKind: stream.integrationKind,
|
||||
integrationIcon: getIconUrl(stream.integrationKind),
|
||||
integrationName: stream.integrationName,
|
||||
}));
|
||||
|
||||
const playingCount = flatSessions.filter(
|
||||
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
|
||||
).length;
|
||||
const transcodingCount = flatSessions.filter((session) => {
|
||||
const status = getPlaybackStatus(session.currentlyPlaying?.metadata?.transcoding);
|
||||
return status !== "directPlay" && status !== "directStream";
|
||||
}).length;
|
||||
|
||||
const totalBitrateKbps = flatSessions.reduce(
|
||||
(sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0),
|
||||
0,
|
||||
);
|
||||
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
|
||||
const totalBitrateLabel = showBitrate ? formatBitrate(totalBitrateKbps) : null;
|
||||
const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" display="flex">
|
||||
<Stack className={classes.root} gap={0} h="100%" display="flex">
|
||||
{isAdvanced && (
|
||||
<Group px="xs" py={4} gap="xs">
|
||||
<Badge variant="light">{t("summary.sessions", { count: flatSessions.length })}</Badge>
|
||||
<Badge variant="light" color="green">
|
||||
{t("summary.playing", { count: playingCount })}
|
||||
</Badge>
|
||||
<Badge variant="light" color="orange">
|
||||
{t("summary.transcoding", { count: transcodingCount })}
|
||||
</Badge>
|
||||
<Group ml="auto">
|
||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
|
||||
<Group px="xs" justify="flex-end">
|
||||
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
<MantineReactTable table={table} />
|
||||
<Group
|
||||
gap="xs"
|
||||
h={30}
|
||||
px="xs"
|
||||
pr="md"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconVideo size={16} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
</Text>
|
||||
{totalBitrateLabel && (
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
{(isAdvanced || height >= 144) && (
|
||||
<Group
|
||||
gap="xs"
|
||||
h={30}
|
||||
px="xs"
|
||||
pr="md"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconVideo size={16} style={{ flexShrink: 0 }} />
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
|
||||
count: flatSessions.length,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{uniqueIntegrations.map((integration) => (
|
||||
<Group key={integration.integrationKind} gap="xs" align="center">
|
||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
||||
<Text className="media-server-name" size="sm">
|
||||
{integration.integrationName}
|
||||
{totalBitrateLabel && (isAdvanced || width >= 300) && (
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{uniqueIntegrations.map((integration) => (
|
||||
<Group key={integration.integrationId} gap="xs" align="center">
|
||||
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
||||
{(isAdvanced || width >= 480) && (
|
||||
<Text className="media-server-name" size="sm" truncate="end">
|
||||
{integration.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
|
||||
const t = useScopedI18n("widget.mediaServer.items");
|
||||
const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null;
|
||||
function CurrentlyPlaying({ item }: { item: StreamSession }) {
|
||||
const t = useScopedI18n("widget.mediaServer");
|
||||
const currentlyPlaying = item.currentlyPlaying;
|
||||
if (!currentlyPlaying) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.sessionName}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
return innerProps.item.currentlyPlaying?.metadata
|
||||
? constructMetadata(innerProps.item.currentlyPlaying.metadata)
|
||||
const playback = currentlyPlaying.playback;
|
||||
const isPaused = playback?.state === "paused";
|
||||
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
|
||||
const positionMs = playback?.positionMs ?? null;
|
||||
const durationMs = playback?.durationMs ?? null;
|
||||
const progressPercent =
|
||||
positionMs !== null && durationMs !== null && durationMs > 0
|
||||
? Math.min(100, Math.round((positionMs / durationMs) * 100))
|
||||
: null;
|
||||
}, [innerProps.item.currentlyPlaying?.metadata]);
|
||||
const remainingMinutes =
|
||||
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
|
||||
|
||||
return (
|
||||
<Stack align="center">
|
||||
<Flex direction="column" gap="xs" align="center">
|
||||
{Icon && innerProps.item.currentlyPlaying !== null && (
|
||||
<Group gap="sm" align="center">
|
||||
<Icon size={24} />
|
||||
<Title order={2}>{innerProps.item.currentlyPlaying.name}</Title>
|
||||
</Group>
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
|
||||
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{currentlyPlaying.name}
|
||||
</Text>
|
||||
{isPaused ? (
|
||||
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
|
||||
{t("items.paused")}
|
||||
</Text>
|
||||
) : (
|
||||
remainingMinutes !== null && (
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{t("items.remaining", { minutes: remainingMinutes.toString() })}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
{innerProps.item.currentlyPlaying?.episodeName && (
|
||||
<Group>
|
||||
<Title order={4}>{innerProps.item.currentlyPlaying.episodeName}</Title>
|
||||
{innerProps.item.currentlyPlaying.seasonName && (
|
||||
<>
|
||||
{" - "}
|
||||
<Title order={4}>{innerProps.item.currentlyPlaying.seasonName}</Title>
|
||||
</>
|
||||
</Group>
|
||||
{progressPercent !== null && (
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
size={4}
|
||||
color={isPaused ? "yellow" : "green"}
|
||||
style={{ backgroundColor: "var(--mantine-color-default-border)" }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionDetailsPopover({
|
||||
item,
|
||||
opened,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
item: StreamSession;
|
||||
opened: boolean;
|
||||
onChange: (opened: boolean) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={onChange}
|
||||
position="bottom-start"
|
||||
width="min(26.25rem, calc(100vw - 1.5rem))"
|
||||
shadow="md"
|
||||
withArrow
|
||||
withinPortal
|
||||
returnFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
className={classes.detailsTarget}
|
||||
aria-label={item.sessionName}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange(!opened);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<SessionDetails item={item} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetails({ item }: { item: StreamSession }) {
|
||||
const t = useScopedI18n("widget.mediaServer.items");
|
||||
const Icon = item.currentlyPlaying ? mediaTypeIconMap[item.currentlyPlaying.type] : null;
|
||||
|
||||
const metadata = item.currentlyPlaying?.metadata ? constructMetadata(item.currentlyPlaying.metadata) : null;
|
||||
|
||||
return (
|
||||
<Stack gap="sm" style={{ minWidth: 0 }}>
|
||||
{Icon && item.currentlyPlaying && (
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
<Icon size={22} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} lineClamp={2}>
|
||||
{item.currentlyPlaying.name}
|
||||
</Text>
|
||||
{item.currentlyPlaying.episodeName && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{[item.currentlyPlaying.episodeName, item.currentlyPlaying.seasonName].filter(Boolean).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
<NormalizedLine
|
||||
itemKey={t("user")}
|
||||
value={
|
||||
<Group gap="sm" align="center">
|
||||
<Avatar size="sm" src={innerProps.item.user.profilePictureUrl} />{" "}
|
||||
<Text>{innerProps.item.user.username}</Text>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<Avatar size="xs" src={item.user?.profilePictureUrl} />
|
||||
<Text size="sm" truncate>
|
||||
{item.user?.username ?? t("unknownUser")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<NormalizedLine itemKey={t("name")} value={<Text>{innerProps.item.sessionName}</Text>} />
|
||||
<NormalizedLine itemKey={t("id")} value={<Text>{innerProps.item.sessionId}</Text>} />
|
||||
<NormalizedLine
|
||||
itemKey={t("name")}
|
||||
value={
|
||||
<Text size="sm" truncate>
|
||||
{item.sessionName}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<NormalizedLine
|
||||
itemKey={t("id")}
|
||||
value={
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{item.sessionId}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
{metadata ? (
|
||||
<Stack w="100%" gap={0}>
|
||||
<Divider label={t("metadata.title")} labelPosition="center" mt="lg" mb="sm" />
|
||||
|
||||
<Group align="flex-start">
|
||||
{objectEntries(metadata).map(([key, value], index) => (
|
||||
<Fragment key={key}>
|
||||
{index !== 0 && <Divider key={index} orientation="vertical" />}
|
||||
<Stack gap={4}>
|
||||
<Text fw="bold">{t(`metadata.${key}.title`)}</Text>
|
||||
|
||||
{Object.entries(value)
|
||||
.filter(([_, value]) => Boolean(value))
|
||||
.map(([innerKey, value]) => (
|
||||
<Group justify="space-between" w="100%" key={innerKey} wrap="nowrap">
|
||||
<Text>{t(`metadata.${key}.${innerKey}` as never)}</Text>
|
||||
<Text>{value}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Fragment>
|
||||
<Stack gap="xs">
|
||||
<Divider label={t("metadata.title")} labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm" verticalSpacing="xs">
|
||||
{objectEntries(metadata).map(([key, entries]) => (
|
||||
<Stack key={key} gap={4} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="xs">
|
||||
{t(`metadata.${key}.title`)}
|
||||
</Text>
|
||||
{Object.entries(entries)
|
||||
.filter(([_, entryValue]) => Boolean(entryValue))
|
||||
.map(([innerKey, entryValue]) => (
|
||||
<Group justify="space-between" gap="xs" key={innerKey} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`metadata.${key}.${innerKey}` as never)}
|
||||
</Text>
|
||||
<Text size="xs" ta="right" truncate>
|
||||
{entryValue}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Group>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle() {
|
||||
return "";
|
||||
},
|
||||
size: "lg",
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
|
||||
const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => {
|
||||
return (
|
||||
<Group w="100%" align="top" justify="space-between">
|
||||
<Text>{itemKey}:</Text>
|
||||
{value}
|
||||
<Group w="100%" gap="md" align="flex-start" justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{itemKey}:
|
||||
</Text>
|
||||
<Stack gap={0} align="flex-end" style={{ minWidth: 0 }}>
|
||||
{value}
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
|
||||
icon: IconVideo,
|
||||
queryKey: [["widget", "mediaServer"]],
|
||||
refetchInterval: 5,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -30,23 +42,73 @@ export default function MediaTranscodingWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const queuePageSize = 10;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const queuePageSize = getQueuePageSize(height, isAdvanced);
|
||||
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
|
||||
page: 1,
|
||||
pageSize: queuePageSize,
|
||||
isAdvanced,
|
||||
}));
|
||||
const requestPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, null);
|
||||
const input = {
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
pageSize: queuePageSize,
|
||||
page: queuePage,
|
||||
page: requestPagination.page,
|
||||
};
|
||||
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
|
||||
|
||||
const [view, setView] = useState<View>(options.defaultView);
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
const totalQueuePages = transcodingData
|
||||
? Math.max(1, Math.ceil(transcodingData.data.queue.totalCount / queuePageSize))
|
||||
: null;
|
||||
const resolvedPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, totalQueuePages);
|
||||
const queuePage = resolvedPagination.page;
|
||||
|
||||
useEffect(() => {
|
||||
setQueuePagination((current) => {
|
||||
const next = resolveQueuePagination(current, queuePageSize, isAdvanced, totalQueuePages);
|
||||
return current.page === next.page && current.pageSize === next.pageSize && current.isAdvanced === next.isAdvanced
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}, [isAdvanced, queuePageSize, totalQueuePages]);
|
||||
|
||||
const handleQueuePageChange = (page: number) => {
|
||||
setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
|
||||
};
|
||||
|
||||
if (!transcodingData) return <WidgetEmptyState />;
|
||||
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
const isTiny = width < 256;
|
||||
const queuePageCount = totalQueuePages ?? 1;
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack gap="xs" h="100%" p="xs">
|
||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
||||
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
|
||||
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
|
||||
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
|
||||
</AdvancedPanel>
|
||||
<AdvancedPanel title={t("tab.queue")} icon={IconClipboardList}>
|
||||
<QueuePanel queue={transcodingData.data.queue} />
|
||||
</AdvancedPanel>
|
||||
<AdvancedPanel title={t("tab.statistics")} icon={IconReportAnalytics}>
|
||||
<StatisticsPanel statistics={transcodingData.data.statistics} />
|
||||
</AdvancedPanel>
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Pagination total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs" />
|
||||
<HealthCheckStatus statistics={transcodingData.data.statistics} />
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
@@ -89,12 +151,12 @@ export default function MediaTranscodingWidget({
|
||||
<Group gap="xs" ml="auto">
|
||||
{view === "queue" && (
|
||||
<>
|
||||
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="xs">
|
||||
<Pagination.Root total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs">
|
||||
<Group gap={2} justify="center">
|
||||
{!isTiny && <Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />}
|
||||
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
|
||||
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
|
||||
{!isTiny && <Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />}
|
||||
{!isTiny && <Pagination.First disabled={queuePage === 1} />}
|
||||
<Pagination.Previous disabled={queuePage === 1} />
|
||||
<Pagination.Next disabled={queuePage === queuePageCount} />
|
||||
{!isTiny && <Pagination.Last disabled={queuePage === queuePageCount} />}
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
@@ -113,3 +175,40 @@ export default function MediaTranscodingWidget({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const getQueuePageSize = (height: number, isAdvanced: boolean): number => {
|
||||
if (isAdvanced) return 25;
|
||||
return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
|
||||
};
|
||||
|
||||
interface QueuePaginationState {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
export const resolveQueuePagination = (
|
||||
state: QueuePaginationState,
|
||||
pageSize: number,
|
||||
isAdvanced: boolean,
|
||||
totalPages: number | null,
|
||||
): QueuePaginationState => ({
|
||||
page:
|
||||
state.pageSize !== pageSize || state.isAdvanced !== isAdvanced
|
||||
? 1
|
||||
: Math.max(1, totalPages === null ? state.page : Math.min(state.page, totalPages)),
|
||||
pageSize,
|
||||
isAdvanced,
|
||||
});
|
||||
|
||||
const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
|
||||
<Paper withBorder radius="sm" p="xs" mih={280} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<Icon size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
@@ -11,6 +11,8 @@ export const views = ["workers", "queue", "statistics"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
queryKey: [["widget", "mediaTranscoding"]],
|
||||
maxIntegrations: 1,
|
||||
refetchInterval: null,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getQueuePageSize, resolveQueuePagination } from "./component";
|
||||
import { toRingProgressSections } from "./panels/statistics.panel";
|
||||
|
||||
describe("media transcoding layout", () => {
|
||||
test("keeps compact queues bounded to the available height", () => {
|
||||
expect(getQueuePageSize(140, false)).toBe(3);
|
||||
expect(getQueuePageSize(500, false)).toBe(14);
|
||||
expect(getQueuePageSize(2000, false)).toBe(15);
|
||||
});
|
||||
|
||||
test("loads a bounded deeper queue in advanced mode", () => {
|
||||
expect(getQueuePageSize(200, true)).toBe(25);
|
||||
});
|
||||
|
||||
test("resets the queue when display mode or page size changes", () => {
|
||||
const compact = { page: 7, pageSize: 10, isAdvanced: false };
|
||||
|
||||
expect(resolveQueuePagination(compact, 25, true, 8)).toEqual({ page: 1, pageSize: 25, isAdvanced: true });
|
||||
expect(resolveQueuePagination(compact, 12, false, 8)).toEqual({ page: 1, pageSize: 12, isAdvanced: false });
|
||||
});
|
||||
|
||||
test("clamps the queue page when the result count shrinks", () => {
|
||||
expect(resolveQueuePagination({ page: 7, pageSize: 10, isAdvanced: false }, 10, false, 3)).toEqual({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
isAdvanced: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create NaN chart sections for empty statistics", () => {
|
||||
expect(toRingProgressSections([{ name: "none", value: 0 }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -71,8 +71,9 @@ const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
|
||||
export function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
|
||||
const total = segments.reduce((prev, curr) => prev + curr.value, 0);
|
||||
if (total <= 0) return [];
|
||||
return segments.map((segment, index) => ({
|
||||
value: (segment.value * 100) / total,
|
||||
tooltip: `${segment.name}: ${segment.value}`,
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCube, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatNumber } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
|
||||
export default function MinecraftServerStatusWidget({
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"minecraftServerStatus">) {
|
||||
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
|
||||
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
|
||||
const t = useI18n();
|
||||
|
||||
if (!result) return <WidgetEmptyState />;
|
||||
const { data } = result;
|
||||
|
||||
const title = options.title.trim().length > 0 ? options.title : options.domain;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
|
||||
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -25,24 +33,38 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
|
||||
h="100%"
|
||||
w="100%"
|
||||
direction="column"
|
||||
p="sm"
|
||||
p={isAdvanced ? "lg" : "sm"}
|
||||
justify="center"
|
||||
align="center"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
|
||||
<Tooltip
|
||||
label={
|
||||
data.online
|
||||
? t("widget.minecraftServerStatus.status.online")
|
||||
: t("widget.minecraftServerStatus.status.offline")
|
||||
}
|
||||
>
|
||||
<Box miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
|
||||
</Tooltip>
|
||||
<Text size="md" fw="bold">
|
||||
<Text size={isAdvanced ? "xl" : "md"} fw="bold" truncate="end">
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAdvanced && (
|
||||
<Group gap="xs" mt="xs" wrap="wrap" justify="center">
|
||||
<Badge variant="light">{options.domain}</Badge>
|
||||
{options.isBedrockServer && (
|
||||
<Badge variant="outline">{t("widget.minecraftServerStatus.option.isBedrockServer.label")}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{data.online && (
|
||||
<>
|
||||
{!options.isBedrockServer &&
|
||||
(data.icon ? (
|
||||
<img
|
||||
style={{ flex: 1, transform: "scale(0.8)", objectFit: "contain" }}
|
||||
style={{ flex: 1, width: iconSize, maxHeight: iconSize, objectFit: "contain" }}
|
||||
alt={`minecraft icon ${options.domain}`}
|
||||
src={data.icon}
|
||||
/>
|
||||
@@ -55,17 +77,25 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconCube size="3rem" color="var(--mantine-color-gray-5)" />
|
||||
<IconCube size={iconSize} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
))}
|
||||
<Group gap={5} c="gray.6" align="center">
|
||||
<IconUsersGroup size="1rem" />
|
||||
<Text size="md">
|
||||
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={4} w={isAdvanced ? "min(100%, 420px)" : "auto"} align="stretch">
|
||||
<Group gap={5} c="dimmed" align="center" justify="center">
|
||||
<IconUsersGroup size={isAdvanced ? "1.25rem" : "1rem"} />
|
||||
<Text size={isAdvanced ? "lg" : "md"}>
|
||||
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
|
||||
</Text>
|
||||
</Group>
|
||||
{isAdvanced && <Progress value={playerPercent} color={playerPercent >= 90 ? "orange" : "teal"} />}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
{isAdvanced && !data.online && (
|
||||
<Text mt="md" c="dimmed" size="sm">
|
||||
{options.domain}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
@@ -41,11 +42,13 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
|
||||
@@ -154,6 +157,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { Box, Card, SimpleGrid } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import objectSupport from "dayjs/plugin/objectSupport";
|
||||
@@ -20,26 +20,33 @@ dayjs.extend(duration);
|
||||
export default function NetworkControllerNetworkStatusWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerStatus">) {
|
||||
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0);
|
||||
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
|
||||
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
|
||||
const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
|
||||
|
||||
return (
|
||||
<Box p={"sm"}>
|
||||
{options.content === "wifi" ? (
|
||||
<WifiVariant
|
||||
countGuests={data.reduce((sum, summary) => sum + summary.wifi.guests, 0)}
|
||||
countUsers={data.reduce((sum, summary) => sum + summary.wifi.users, 0)}
|
||||
/>
|
||||
) : (
|
||||
<WiredVariant
|
||||
countGuests={data.reduce((sum, summary) => sum + summary.lan.guests, 0)}
|
||||
countUsers={data.reduce((sum, summary) => sum + summary.lan.users, 0)}
|
||||
/>
|
||||
)}
|
||||
<Box p={isAdvanced ? "md" : "sm"} h="100%">
|
||||
<SimpleGrid cols={isAdvanced ? 2 : 1} h="100%" spacing="sm">
|
||||
{(isAdvanced || options.content === "wifi") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
<WifiVariant countGuests={countWifiGuests} countUsers={countWifiUsers} compact={!isAdvanced} />
|
||||
</Card>
|
||||
)}
|
||||
{(isAdvanced || options.content === "wired") && (
|
||||
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
|
||||
<WiredVariant countGuests={countLanGuests} countUsers={countLanUsers} compact={!isAdvanced} />
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
export const StatRow = ({ label, value }: { label: string; value: string | number }) => {
|
||||
export const StatRow = ({
|
||||
label,
|
||||
value,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size={"2xl"} fw={900} lh={1}>
|
||||
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size={"md"} c={"dimmed"}>
|
||||
<Text size={compact ? "sm" : "md"} c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -5,19 +5,27 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
export const WifiVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
|
||||
export const WifiVariant = ({
|
||||
countGuests,
|
||||
countUsers,
|
||||
compact = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
<>
|
||||
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
|
||||
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
|
||||
<IconWifi size={24} />
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wifi.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={"lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} />
|
||||
<Stack gap={compact ? "sm" : "lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,19 +5,27 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { StatRow } from "./stat-row";
|
||||
|
||||
export const WiredVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
|
||||
export const WiredVariant = ({
|
||||
countGuests,
|
||||
countUsers,
|
||||
compact = false,
|
||||
}: {
|
||||
countUsers: number;
|
||||
countGuests: number;
|
||||
compact?: boolean;
|
||||
}) => {
|
||||
const t = useScopedI18n("widget.networkControllerStatus.card");
|
||||
return (
|
||||
<>
|
||||
<Group gap={"xs"} wrap={"nowrap"} mb={"md"}>
|
||||
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
|
||||
<IconNetwork size={24} />
|
||||
<Text size={"md"} fw={"bold"}>
|
||||
{t("variants.wired.name")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={"lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} />
|
||||
<Stack gap={compact ? "sm" : "lg"}>
|
||||
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
|
||||
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Box, Center, List, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Badge, Card, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
@@ -19,6 +18,8 @@ dayjs.extend(duration);
|
||||
|
||||
export default function NetworkControllerSummaryWidget({
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"networkControllerSummary">) {
|
||||
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
|
||||
integrationIds,
|
||||
@@ -26,33 +27,57 @@ export default function NetworkControllerSummaryWidget({
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<Box h="100%" p="sm">
|
||||
<Center h={"100%"}>
|
||||
<List spacing={"xs"} center>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.wanStatus} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.www.status} />}>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c={"dimmed"} size={"md"} ms={"xs"} span>
|
||||
{data[0]?.www.latency}ms
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.wifi.status} />}>Wi-Fi</List.Item>
|
||||
<List.Item icon={<StatusIcon status={data[0]?.vpn.status} />}>
|
||||
<Text>
|
||||
VPN
|
||||
<Text c={"dimmed"} size={"md"} ms={"xs"} span>
|
||||
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${data[0]?.vpn.users}` })}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Center>
|
||||
</Box>
|
||||
<ScrollArea h="100%" p={isAdvanced ? "md" : "sm"}>
|
||||
<SimpleGrid cols={columns} spacing="sm">
|
||||
{summaries.map(({ integration, summary }) => (
|
||||
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
|
||||
<Stack gap="xs">
|
||||
{(isAdvanced || summaries.length > 1) && (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate="end">
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{integration.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
<List spacing="xs" center>
|
||||
<List.Item icon={<StatusIcon status={summary.wanStatus} />}>WAN</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.www.status} />}>
|
||||
<Text>
|
||||
WWW
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
{summary.www.latency}ms{isAdvanced ? ` · ${summary.www.ping}ms` : ""}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item icon={<StatusIcon status={summary.wifi.status} />}>
|
||||
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
|
||||
</List.Item>
|
||||
{isAdvanced && (
|
||||
<List.Item icon={<StatusIcon status={summary.lan.status} />}>
|
||||
LAN · {summary.lan.users + summary.lan.guests}
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item icon={<StatusIcon status={summary.vpn.status} />}>
|
||||
<Text>
|
||||
VPN
|
||||
<Text c="dimmed" size="sm" ms="xs" span>
|
||||
{t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${summary.vpn.users}` })}
|
||||
</Text>
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,5 +86,6 @@ const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => {
|
||||
if (status === "enabled") {
|
||||
return <IconCircleCheckFilled size={20} color={mantineTheme.colors.green[6]} />;
|
||||
}
|
||||
return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
|
||||
if (status === "disabled") return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
|
||||
return <IconCircleXFilled size={20} color={mantineTheme.colors.gray[6]} />;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineColorScheme,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
@@ -89,16 +91,63 @@ const controlIconProps = {
|
||||
stroke: 1.5,
|
||||
};
|
||||
|
||||
export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
|
||||
const [content, setContent] = useState(options.content);
|
||||
const previousContentRef = useRef(content);
|
||||
interface NotebookPersistedState {
|
||||
itemId: string | undefined;
|
||||
content: string;
|
||||
previousContent: string;
|
||||
isEditing: boolean;
|
||||
saveError: string | null;
|
||||
}
|
||||
|
||||
export function Notebook({
|
||||
options,
|
||||
setOptions,
|
||||
isEditMode,
|
||||
boardId,
|
||||
itemId,
|
||||
displayMode,
|
||||
height,
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"notebook">) {
|
||||
const persistedState = widgetStateRef?.current?.notebook as NotebookPersistedState | undefined;
|
||||
const canHydratePersistedState = persistedState?.itemId === itemId;
|
||||
const initialContent = canHydratePersistedState ? (persistedState?.content ?? options.content) : options.content;
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const previousContentRef = useRef(
|
||||
canHydratePersistedState ? (persistedState?.previousContent ?? initialContent) : initialContent,
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(
|
||||
canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
|
||||
);
|
||||
|
||||
const board = useRequiredBoard();
|
||||
const { data: session } = useSession();
|
||||
const { hasChangeAccess } = constructBoardPermissions(board, session);
|
||||
|
||||
const canChange = !isEditMode && hasChangeAccess;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
|
||||
const canChangeRef = useRef(canChange);
|
||||
const allowReadOnlyCheckRef = useRef(options.allowReadOnlyCheck);
|
||||
const readOnlyCheckEventName = `homarr:notebook-read-only-check:${useId()}`;
|
||||
|
||||
useEffect(() => {
|
||||
canChangeRef.current = canChange;
|
||||
allowReadOnlyCheckRef.current = options.allowReadOnlyCheck;
|
||||
}, [canChange, options.allowReadOnlyCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
notebook: {
|
||||
itemId,
|
||||
content,
|
||||
previousContent: previousContentRef.current,
|
||||
isEditing,
|
||||
saveError,
|
||||
},
|
||||
};
|
||||
}, [content, isEditing, itemId, saveError, widgetStateRef]);
|
||||
|
||||
const { primaryColor } = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
@@ -108,6 +157,37 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
const tControls = useScopedI18n("widget.notebook.controls");
|
||||
const t = useI18n();
|
||||
|
||||
const handleContentUpdate = useCallback(
|
||||
async (contentUpdate: string) => {
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
if (boardId && itemId) {
|
||||
await mutateAsync({ boardId, itemId, content: contentUpdate });
|
||||
}
|
||||
previousContentRef.current = contentUpdate;
|
||||
if (widgetStateRef) {
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
notebook: {
|
||||
itemId,
|
||||
content: contentUpdate,
|
||||
previousContent: contentUpdate,
|
||||
isEditing,
|
||||
saveError: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
setOptions({ newOptions: { content: contentUpdate } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed"));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[boardId, isEditing, itemId, mutateAsync, setOptions, t, widgetStateRef],
|
||||
);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
@@ -162,10 +242,10 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
onReadOnlyChecked: (node, checked) => {
|
||||
if (!options.allowReadOnlyCheck) return false;
|
||||
if (!canChange) return false;
|
||||
if (!allowReadOnlyCheckRef.current) return false;
|
||||
if (!canChangeRef.current) return false;
|
||||
|
||||
const event = new CustomEvent("onReadOnlyCheck", {
|
||||
const event = new CustomEvent(readOnlyCheckEventName, {
|
||||
detail: { node, checked },
|
||||
});
|
||||
dispatchEvent(event);
|
||||
@@ -184,60 +264,51 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
setContent(editor.getHTML());
|
||||
},
|
||||
onCreate: ({ editor }) => {
|
||||
editor.setEditable(false);
|
||||
editor.setEditable(isEditing);
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOnReadOnlyCheck = (event: CustomEventInit<{ node: Node; checked: boolean }>) => {
|
||||
if (!options.allowReadOnlyCheck) return;
|
||||
if (!editor) return;
|
||||
const handleOnReadOnlyCheck = useCallback(
|
||||
(event: Event) => {
|
||||
if (!allowReadOnlyCheckRef.current) return;
|
||||
if (!editor) return;
|
||||
const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
|
||||
|
||||
editor.state.doc.descendants((subnode, pos) => {
|
||||
if (!event.detail) return;
|
||||
if (!subnode.eq(event.detail.node)) return;
|
||||
editor.state.doc.descendants((subnode, pos) => {
|
||||
if (!detail) return;
|
||||
if (!subnode.eq(detail.node)) return;
|
||||
|
||||
const { tr } = editor.state;
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...event.detail.node.attrs,
|
||||
checked: event.detail.checked,
|
||||
const { tr } = editor.state;
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...detail.node.attrs,
|
||||
checked: detail.checked,
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
const nextContent = editor.getHTML();
|
||||
setContent(nextContent);
|
||||
void handleContentUpdate(nextContent).then((wasSaved) => {
|
||||
if (wasSaved) return;
|
||||
setContent(previousContentRef.current);
|
||||
editor.commands.setContent(previousContentRef.current);
|
||||
});
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
setContent(editor.getHTML());
|
||||
handleContentUpdate(editor.getHTML());
|
||||
});
|
||||
};
|
||||
|
||||
addEventListener("onReadOnlyCheck", handleOnReadOnlyCheck);
|
||||
|
||||
const handleContentUpdate = useCallback(
|
||||
(contentUpdate: string) => {
|
||||
previousContentRef.current = contentUpdate;
|
||||
setOptions({ newOptions: { content: contentUpdate } });
|
||||
|
||||
// This is not available in preview mode
|
||||
if (boardId && itemId) {
|
||||
void mutateAsync({ boardId, itemId, content: contentUpdate });
|
||||
}
|
||||
},
|
||||
[boardId, itemId, mutateAsync, setOptions],
|
||||
[editor, handleContentUpdate],
|
||||
);
|
||||
|
||||
const handleEditToggleCallback = useCallback(
|
||||
(previous: boolean) => {
|
||||
const current = !previous;
|
||||
if (!editor) return current;
|
||||
editor.setEditable(current);
|
||||
useEffect(() => {
|
||||
addEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
|
||||
return () => removeEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
|
||||
}, [handleOnReadOnlyCheck, readOnlyCheckEventName]);
|
||||
|
||||
if (previous) {
|
||||
handleContentUpdate(content);
|
||||
}
|
||||
|
||||
return current;
|
||||
},
|
||||
[content, editor, handleContentUpdate],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (isEditing || !editor || options.content === previousContentRef.current) return;
|
||||
previousContentRef.current = options.content;
|
||||
setContent(options.content);
|
||||
editor.commands.setContent(options.content);
|
||||
}, [editor, isEditing, options.content]);
|
||||
|
||||
const handleEditCancelCallback = useCallback(() => {
|
||||
if (!editor) return false;
|
||||
@@ -264,9 +335,20 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
});
|
||||
}, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]);
|
||||
|
||||
const handleEditToggle = useCallback(() => {
|
||||
setIsEditing(handleEditToggleCallback);
|
||||
}, [setIsEditing, handleEditToggleCallback]);
|
||||
const handleEditToggle = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
if (!isEditing) {
|
||||
setSaveError(null);
|
||||
editor.setEditable(true);
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSaved = await handleContentUpdate(content);
|
||||
if (!wasSaved) return;
|
||||
editor.setEditable(false);
|
||||
setIsEditing(false);
|
||||
}, [content, editor, handleContentUpdate, isEditing]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -276,18 +358,26 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
if (event.target instanceof Element && event.target.closest("button, a")) {
|
||||
return;
|
||||
}
|
||||
setIsEditing(handleEditToggleCallback);
|
||||
setSaveError(null);
|
||||
editor?.setEditable(true);
|
||||
setIsEditing(true);
|
||||
},
|
||||
[canChange, isEditing, setIsEditing, handleEditToggleCallback],
|
||||
[canChange, editor, isEditing],
|
||||
);
|
||||
|
||||
const documentText = editor?.getText().trim() ?? "";
|
||||
const documentStats = {
|
||||
characters: documentText.length,
|
||||
words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box h="100%" onDoubleClick={handleDoubleClick}>
|
||||
<RichTextEditor
|
||||
p={0}
|
||||
mt={0}
|
||||
h="100%"
|
||||
onKeyDown={isEditing ? getHotkeyHandler([[hotkeys.saveNotebook, handleEditToggle]]) : undefined}
|
||||
onKeyDown={isEditing ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined}
|
||||
editor={editor}
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
@@ -304,7 +394,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
},
|
||||
content: {
|
||||
backgroundColor: "transparent",
|
||||
padding: "0.5rem",
|
||||
padding: height < 120 && displayMode !== "advanced" ? "0.25rem" : "0.5rem",
|
||||
height: "100%",
|
||||
},
|
||||
typographyStylesProvider: {
|
||||
@@ -314,7 +404,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
>
|
||||
<RichTextEditor.Toolbar
|
||||
style={{
|
||||
display: isEditing && options.showToolbar === true ? "flex" : "none",
|
||||
display: isEditing && options.showToolbar === true && displayMode === "advanced" ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
@@ -442,6 +532,27 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
<RichTextEditor.Content />
|
||||
</ScrollArea>
|
||||
</RichTextEditor>
|
||||
{displayMode === "advanced" && (
|
||||
<Group pos="absolute" bottom={4} right={8} gap="xs">
|
||||
{saveError && (
|
||||
<Tooltip label={saveError} multiline>
|
||||
<Text
|
||||
size="xs"
|
||||
c="red"
|
||||
lineClamp={1}
|
||||
maw={320}
|
||||
tabIndex={0}
|
||||
aria-label={`${t("widget.notebook.saveFailed")}. ${saveError}`}
|
||||
>
|
||||
{t("widget.notebook.saveFailed")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.notebook.documentStats", documentStats)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{canChange && (
|
||||
<>
|
||||
<ActionIcon
|
||||
@@ -455,7 +566,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
|
||||
color={primaryColor}
|
||||
variant="light"
|
||||
size={30}
|
||||
onClick={handleEditToggle}
|
||||
onClick={() => void handleEditToggle()}
|
||||
>
|
||||
{isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,78 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
|
||||
export default function NotificationsWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"notifications">) {
|
||||
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
|
||||
...options,
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const t = useScopedI18n("widget.notifications");
|
||||
const t = useI18n();
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const sortedNotifications = useMemo(
|
||||
() =>
|
||||
notificationIntegrations
|
||||
.flatMap((integration) => integration.data)
|
||||
.flatMap((integration) =>
|
||||
integration.data.map((notification) => ({
|
||||
...notification,
|
||||
compositeKey: `${integration.integration.id}:${notification.id}`,
|
||||
integrationName: integration.integration.name,
|
||||
})),
|
||||
)
|
||||
.sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()),
|
||||
[notificationIntegrations],
|
||||
);
|
||||
const failedIntegrations = notificationIntegrations.filter(
|
||||
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
|
||||
);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const columns = isAdvanced && width >= 720 ? 2 : 1;
|
||||
|
||||
return (
|
||||
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||
<Stack w={"100%"} gap="sm">
|
||||
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
|
||||
<Stack w="100%" gap="xs">
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{failedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.integration.id} label={integration.error} multiline maw={360}>
|
||||
<Badge color="red" variant="light" size="xs">
|
||||
{integration.integration.name}: {t("common.error")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{sortedNotifications.length > 0 ? (
|
||||
sortedNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
component={notification.href ? "a" : "div"}
|
||||
href={notification.href}
|
||||
target={notification.href ? "_blank" : undefined}
|
||||
rel={notification.href ? "noopener noreferrer" : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p="sm"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<Flex gap="sm" align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex gap="sm" direction="column" w="100%">
|
||||
{notification.title && (
|
||||
<Text fz="sm" lh="sm" lineClamp={2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
|
||||
{sortedNotifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.compositeKey}
|
||||
component={notification.href ? "a" : "div"}
|
||||
href={notification.href}
|
||||
target={notification.href ? "_blank" : undefined}
|
||||
rel={notification.href ? "noopener noreferrer" : undefined}
|
||||
radius={board.itemRadius}
|
||||
w="100%"
|
||||
p={isAdvanced ? "md" : "xs"}
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<Flex gap="sm" align="flex-start" w="100%">
|
||||
{!options.hideLogos && notification.source?.iconUrl && (
|
||||
<Avatar
|
||||
src={notification.source.iconUrl}
|
||||
alt={notification.source.name}
|
||||
size="sm"
|
||||
radius={board.itemRadius}
|
||||
/>
|
||||
)}
|
||||
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay date={notification.time} />
|
||||
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
|
||||
{notification.title && (
|
||||
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
)}
|
||||
<Text c="dimmed" size="sm" lineClamp={isAdvanced ? 12 : 4} style={{ whiteSpace: "pre-line" }}>
|
||||
{notification.body}
|
||||
</Text>
|
||||
|
||||
<InfoDisplay
|
||||
date={notification.time}
|
||||
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("noItems")}
|
||||
{t("widget.notifications.noItems")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -80,15 +112,21 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
|
||||
);
|
||||
}
|
||||
|
||||
const InfoDisplay = ({ date }: { date: Date }) => {
|
||||
const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => {
|
||||
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
|
||||
|
||||
return (
|
||||
<Group gap={5} align={"center"}>
|
||||
<Group gap={5} align="center" wrap="nowrap">
|
||||
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{timeAgo}
|
||||
</Text>
|
||||
{source && <Text c="dimmed">•</Text>}
|
||||
{source && (
|
||||
<Text size="xs" c="dimmed" truncate="end">
|
||||
{source}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,18 @@ import { IconMessage } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconMessage,
|
||||
queryKey: [["widget", "notifications", "getNotifications"]],
|
||||
queryMatcher: ({ input }, scope) =>
|
||||
widgetQueryInputMatches(input, {
|
||||
hideLogos: scope.options.hideLogos,
|
||||
integrationIds: scope.integrationIds,
|
||||
}),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
hideLogos: factory.switch({ defaultValue: false }),
|
||||
|
||||
@@ -56,6 +56,8 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||
query: string,
|
||||
integrationIds: string[],
|
||||
options: Record<string, unknown>,
|
||||
itemId?: string,
|
||||
boardId?: string,
|
||||
) => {
|
||||
isPending: boolean;
|
||||
options: DynamicSelectOption[];
|
||||
|
||||
@@ -70,7 +70,13 @@ const heroPartVisibility = {
|
||||
ring: "showInboxRing",
|
||||
} as const;
|
||||
|
||||
export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
|
||||
export default function PaperlessNgxWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"paperlessNgx">) {
|
||||
const t = useScopedI18n("widget.paperlessNgx");
|
||||
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
@@ -95,18 +101,20 @@ export default function PaperlessNgxWidget({ integrationIds, options, width }: W
|
||||
.map(([, statKey]) => statKey)
|
||||
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
|
||||
|
||||
const gridCols = getGridCols(width);
|
||||
const ringSize = getRingSize(width);
|
||||
const advanced = displayMode === "advanced";
|
||||
const gridCols = advanced ? Math.max(1, Math.min(5, visibleStatKeys.length)) : getGridCols(width);
|
||||
const ringSize = advanced ? 112 : getRingSize(width);
|
||||
const iconSize = getIconSize(width);
|
||||
const ringLabelSize = getRingLabelSize(ringSize);
|
||||
const hasContent = showHero || visibleStatKeys.length > 0;
|
||||
|
||||
const heroLayoutClass =
|
||||
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
|
||||
const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
|
||||
const showInboxRing = options.showInboxRing && (advanced || height >= 120);
|
||||
const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
|
||||
|
||||
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
|
||||
([, optionKey]) => options[optionKey as keyof typeof options],
|
||||
([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
|
||||
);
|
||||
|
||||
const heroPartRenderers = {
|
||||
@@ -140,7 +148,11 @@ export default function PaperlessNgxWidget({ integrationIds, options, width }: W
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
className={classes.root}
|
||||
data-display-mode={displayMode}
|
||||
style={advanced ? { padding: 16, gap: 16 } : undefined}
|
||||
>
|
||||
{showHero && (
|
||||
<div className={`${classes.hero} ${heroLayoutClass} ${heroRingClass}`}>
|
||||
{visibleHeroParts.map(([partKey]) => (
|
||||
|
||||
@@ -5,9 +5,11 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
|
||||
icon: IconFileText,
|
||||
queryKey: [["widget", "paperlessNgx"]],
|
||||
refetchInterval: null,
|
||||
supportedIntegrations: ["paperlessNgx"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showInboxRatio: factory.switch({
|
||||
|
||||
@@ -75,7 +75,13 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
|
||||
return "comfortable";
|
||||
};
|
||||
|
||||
export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
|
||||
export default function PatchMonWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
}: WidgetComponentProps<"patchmon">) {
|
||||
const t = useScopedI18n("widget.patchmon");
|
||||
const integrationId = integrationIds[0] ?? "";
|
||||
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
|
||||
@@ -98,18 +104,20 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const colorContext = { totalHosts: stats.totalHosts };
|
||||
const layout = getLayoutMode(width, height);
|
||||
const isLandscape = width / height > 2.5;
|
||||
const advanced = displayMode === "advanced";
|
||||
const layout = advanced ? "spacious" : getLayoutMode(width, height);
|
||||
const isLandscape = !advanced && width / height > 2.5;
|
||||
const isTinySquare = width < 160 && height < 160;
|
||||
const isShortLandscape = width >= 160 && height < 160;
|
||||
const isCompactSurface = isTinySquare || isShortLandscape;
|
||||
const isCompactSurface = !advanced && (isTinySquare || isShortLandscape);
|
||||
const isNarrow = width < 180;
|
||||
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
|
||||
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
|
||||
|
||||
const showIcons = layout !== "mini";
|
||||
const showLabels = layout !== "mini";
|
||||
const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
|
||||
const showHero =
|
||||
options.showComplianceHero && (advanced || !isLandscape || isCompactSurface) && width >= 90 && height >= 90;
|
||||
const showHeroText = showHero && shouldShowComplianceHeroText(width);
|
||||
const showHeroRingOnly = showHero && !showHeroText;
|
||||
const compactPrimaryContent = isCompactSurface
|
||||
@@ -120,10 +128,10 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
})
|
||||
: null;
|
||||
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
|
||||
const showOsSection = showOsData && !isCompactSurface && height > 200;
|
||||
const showOsSection = showOsData && !isCompactSurface && (advanced || height > 200);
|
||||
const showCompactFooter = isCompactSurface && height >= 110;
|
||||
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
|
||||
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
|
||||
const showFooter = advanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
|
||||
const showOsLegend = osDisplayMode === "donut" && (advanced || (width >= 380 && height >= 300));
|
||||
|
||||
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
|
||||
|
||||
@@ -272,10 +280,10 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
</div>
|
||||
);
|
||||
|
||||
const needsScroll = layout === "mini" || layout === "compact";
|
||||
const needsScroll = !advanced && (layout === "mini" || layout === "compact");
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.root} data-display-mode={displayMode}>
|
||||
{showHero && (
|
||||
<ComplianceHero
|
||||
compliancePercent={compliancePercent}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
|
||||
icon: IconShieldCheck,
|
||||
queryKey: [["widget", "patchmon"]],
|
||||
supportedIntegrations: ["patchmon"],
|
||||
integrationsRequired: true,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
@@ -188,18 +190,42 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon"
|
||||
useCustomThresholds: {
|
||||
shouldHide: (options) => !options.enableThresholdColors,
|
||||
},
|
||||
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
|
||||
hostsNeedingUpdatesThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsNeedingUpdatesWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsNeedingUpdatesCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
securityUpdatesThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
securityUpdatesWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
securityUpdatesCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsWithSecurityUpdatesThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsWithSecurityUpdatesWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
hostsWithSecurityUpdatesCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
upToDateHostsThresholdMode: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
upToDateHostsWarningAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
upToDateHostsCriticalAt: {
|
||||
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
|
||||
},
|
||||
totalOutdatedPackagesThresholdMode: {
|
||||
shouldHide: (options) =>
|
||||
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
|
||||
|
||||
@@ -9,16 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
const OS_COLORS = [
|
||||
"blue.6",
|
||||
"orange.6",
|
||||
"green.6",
|
||||
"grape.6",
|
||||
"cyan.6",
|
||||
"pink.6",
|
||||
"yellow.6",
|
||||
"teal.6",
|
||||
] as const;
|
||||
const OS_COLORS = ["blue.6", "orange.6", "green.6", "grape.6", "cyan.6", "pink.6", "yellow.6", "teal.6"] as const;
|
||||
|
||||
interface OsDistributionSectionProps {
|
||||
entries: PatchMonOsDistributionEntry[];
|
||||
@@ -73,13 +64,7 @@ export function OsDistributionSection({
|
||||
)}
|
||||
<div className={`${classes.osDonutContent} ${showLegend ? classes.osDonutContentWithLegend : ""}`}>
|
||||
<div className={classes.osDonut}>
|
||||
<DonutChart
|
||||
data={chartData}
|
||||
size={donutSize}
|
||||
withTooltip
|
||||
tooltipDataSource="segment"
|
||||
paddingAngle={2}
|
||||
/>
|
||||
<DonutChart data={chartData} size={donutSize} withTooltip tooltipDataSource="segment" paddingAngle={2} />
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div className={classes.osLegend}>
|
||||
@@ -115,7 +100,12 @@ export function OsDistributionSection({
|
||||
<div className={classes.compactOsBars}>
|
||||
{compactEntries.map((entry, i) => (
|
||||
<div key={`${entry.name}-${entry.osVersion ?? ""}`} className={classes.compactOsBar}>
|
||||
<Text size="xs" className={classes.compactOsLabel} truncate="end" title={formatOsLabel(entry, showOsVersion)}>
|
||||
<Text
|
||||
size="xs"
|
||||
className={classes.compactOsLabel}
|
||||
truncate="end"
|
||||
title={formatOsLabel(entry, showOsVersion)}
|
||||
>
|
||||
{formatOsLabel(entry, showOsVersion)}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" className={classes.osBarGroup}>
|
||||
|
||||
@@ -13,11 +13,7 @@ export type PatchMonStatKey =
|
||||
|
||||
export type ColorablePatchMonStatKey = Extract<
|
||||
PatchMonStatKey,
|
||||
| "hostsNeedingUpdates"
|
||||
| "securityUpdates"
|
||||
| "hostsWithSecurityUpdates"
|
||||
| "upToDateHosts"
|
||||
| "totalOutdatedPackages"
|
||||
"hostsNeedingUpdates" | "securityUpdates" | "hostsWithSecurityUpdates" | "upToDateHosts" | "totalOutdatedPackages"
|
||||
>;
|
||||
|
||||
export type StatSeverity = "green" | "yellow" | "red" | "neutral";
|
||||
|
||||
@@ -117,11 +117,16 @@ describe("resolveStatColor options", () => {
|
||||
test("handles zero total hosts for percent-based stats", () => {
|
||||
expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green");
|
||||
expect(
|
||||
resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, {
|
||||
...defaultOptions,
|
||||
useCustomThresholds: true,
|
||||
hostsNeedingUpdatesThresholdMode: "percent",
|
||||
}),
|
||||
resolveStatColor(
|
||||
"hostsNeedingUpdates",
|
||||
1,
|
||||
{ totalHosts: 0 },
|
||||
{
|
||||
...defaultOptions,
|
||||
useCustomThresholds: true,
|
||||
hostsNeedingUpdatesThresholdMode: "percent",
|
||||
},
|
||||
),
|
||||
).toBe("red");
|
||||
});
|
||||
|
||||
|
||||
@@ -23,25 +23,27 @@ const baseOptions = {
|
||||
totalOutdatedPackagesCriticalAt: 50,
|
||||
};
|
||||
|
||||
const optionsSchema = z.object({
|
||||
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
|
||||
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
|
||||
securityUpdatesWarningAt: z.number().int().min(0),
|
||||
securityUpdatesCriticalAt: z.number().int().min(0),
|
||||
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
|
||||
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
|
||||
upToDateHostsWarningAt: z.number().int().min(0),
|
||||
upToDateHostsCriticalAt: z.number().int().min(0),
|
||||
totalOutdatedPackagesWarningAt: z.number().int().min(0),
|
||||
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
|
||||
enableThresholdColors: z.boolean(),
|
||||
useCustomThresholds: z.boolean(),
|
||||
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
|
||||
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
}).superRefine(patchmonOptionsSuperRefine);
|
||||
const optionsSchema = z
|
||||
.object({
|
||||
hostsNeedingUpdatesWarningAt: z.number().int().min(0),
|
||||
hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
|
||||
securityUpdatesWarningAt: z.number().int().min(0),
|
||||
securityUpdatesCriticalAt: z.number().int().min(0),
|
||||
hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
|
||||
hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
|
||||
upToDateHostsWarningAt: z.number().int().min(0),
|
||||
upToDateHostsCriticalAt: z.number().int().min(0),
|
||||
totalOutdatedPackagesWarningAt: z.number().int().min(0),
|
||||
totalOutdatedPackagesCriticalAt: z.number().int().min(0),
|
||||
enableThresholdColors: z.boolean(),
|
||||
useCustomThresholds: z.boolean(),
|
||||
hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
|
||||
totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
|
||||
})
|
||||
.superRefine(patchmonOptionsSuperRefine);
|
||||
|
||||
describe("patchmonOptionsSuperRefine", () => {
|
||||
test("accepts percent thresholds within 0-100", () => {
|
||||
|
||||
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user