fix: complete widget modernization review
This commit is contained in:
35 files changed
+621
-265
No files matched your search
@@ -8,6 +8,7 @@ import { formatBytes, formatDuration } from "@homarr/common";
|
||||
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { AudioStatsContent } from "./audio-stats-content";
|
||||
@@ -47,9 +48,15 @@ export default function AudioStatsWidget({
|
||||
const sessions = streamResults.flatMap((result) =>
|
||||
result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
|
||||
);
|
||||
const hasStreamFailures = streamResults.some(({ error }) => Boolean(error));
|
||||
const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
|
||||
return (
|
||||
<Stack h="100%" gap="lg" p="md">
|
||||
{hasStreamFailures && (
|
||||
<Group justify="flex-end">
|
||||
<IntegrationErrorIndicator results={streamResults} />
|
||||
</Group>
|
||||
)}
|
||||
<div style={{ minHeight: 150 }}>{summary}</div>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||
{response.kind === "navidrome" ? (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -85,7 +85,7 @@ export default function BeszelAlertsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</Box>
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
|
||||
@@ -36,7 +36,7 @@ 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 { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
|
||||
@@ -406,7 +406,7 @@ export default function BeszelSystemGridWidget({
|
||||
if (filteredSystems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
@@ -436,7 +436,7 @@ export default function BeszelSystemGridWidget({
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
|
||||
@@ -16,7 +16,7 @@ import classes from "./component.module.css";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function BeszelSystemStatsWidget({
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
@@ -136,7 +136,7 @@ export default function BeszelSystemStatsWidget({
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
<IntegrationErrorIndicator results={systemsResult} />
|
||||
</Box>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
|
||||
@@ -40,7 +40,7 @@ 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 { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
import { DiskUsage } from "../beszel/_shared/disk-usage";
|
||||
import { BeszelStatsView } from "../beszel/_shared/stats-view";
|
||||
@@ -426,7 +426,7 @@ export default function BeszelSystemTableWidget({
|
||||
const table = (
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<IntegrationErrorIndicator results={results} />
|
||||
</div>
|
||||
<HomarrDataTable
|
||||
isEditMode={isEditMode}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
interface IntegrationResult {
|
||||
integrationId: string;
|
||||
integrationName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BeszelIntegrationErrorIndicatorProps {
|
||||
results: IntegrationResult[];
|
||||
}
|
||||
|
||||
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
|
||||
const failed = results.filter((r) => "error" in r && r.error);
|
||||
if (failed.length === 0) return null;
|
||||
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Box, Group, Stack, Text, useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
@@ -15,6 +15,7 @@ import { useSettings } from "@homarr/settings";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { setWidgetRuntimeQueries } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
|
||||
import { CalendarEventList } from "./calendar-event-list";
|
||||
@@ -25,7 +26,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
setWidgetRuntimeQueries(props.widgetStateRef, []);
|
||||
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
|
||||
return <CalendarBase {...props} events={[]} failedIntegrations={[]} month={month} setMonth={setMonth} />;
|
||||
}
|
||||
|
||||
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
|
||||
@@ -56,11 +57,16 @@ const FetchCalendar = ({
|
||||
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
const failedIntegrations =
|
||||
data?.flatMap(({ integration, error }) =>
|
||||
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
|
||||
) ?? [];
|
||||
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
failedIntegrations={failedIntegrations}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
@@ -72,13 +78,22 @@ const FetchCalendar = ({
|
||||
interface CalendarBaseProps {
|
||||
isEditMode: boolean;
|
||||
events: CalendarEvent[];
|
||||
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
|
||||
month: Date;
|
||||
setMonth: (date: Date) => void;
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
|
||||
const CalendarBase = ({
|
||||
isEditMode,
|
||||
events,
|
||||
failedIntegrations,
|
||||
month,
|
||||
setMonth,
|
||||
options,
|
||||
displayMode,
|
||||
}: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
@@ -105,6 +120,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
|
||||
|
||||
return (
|
||||
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group px="xs" justify="flex-end">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
</Group>
|
||||
)}
|
||||
<Calendar
|
||||
defaultDate={new Date()}
|
||||
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip, VisuallyHidden } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface IntegrationResult {
|
||||
integrationId: string;
|
||||
integrationName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IntegrationErrorIndicatorProps {
|
||||
results: readonly IntegrationResult[];
|
||||
}
|
||||
|
||||
export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) {
|
||||
const t = useI18n();
|
||||
const failed = results.filter((result) => Boolean(result.error));
|
||||
if (failed.length === 0) return null;
|
||||
|
||||
const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", ");
|
||||
const label = `${t("common.error")}: ${names}`;
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<IconAlertTriangle
|
||||
aria-hidden
|
||||
size={14}
|
||||
color="var(--mantine-color-orange-6)"
|
||||
style={{ cursor: "help", flexShrink: 0 }}
|
||||
/>
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +120,15 @@ export const getWidgetQueryKeys = (definition: {
|
||||
|
||||
const runtimeQueriesStateKey = "__homarrRuntimeQueries";
|
||||
|
||||
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 setWidgetRuntimeQueries = (
|
||||
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null> | undefined,
|
||||
queryKeys: readonly QueryKey[],
|
||||
@@ -127,12 +136,8 @@ export const setWidgetRuntimeQueries = (
|
||||
if (!widgetStateRef) return;
|
||||
const state = widgetStateRef.current ?? {};
|
||||
state[runtimeQueriesStateKey] = queryKeys.flatMap((queryKey) => {
|
||||
const path = queryKey[0];
|
||||
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return [];
|
||||
|
||||
const queryKeyOptions = queryKey[1];
|
||||
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
|
||||
return [{ path, input }];
|
||||
const query = normalizeWidgetQuery(queryKey);
|
||||
return query ? [query] : [];
|
||||
});
|
||||
widgetStateRef.current = state;
|
||||
};
|
||||
|
||||
@@ -41,8 +41,19 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
|
||||
}
|
||||
|
||||
if (props.displayMode === "advanced") {
|
||||
if (props.width < 900) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="sm" p="xs">
|
||||
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} withScrollArea={false} />
|
||||
{clusters}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={props.width >= 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
|
||||
<SimpleGrid cols={2} spacing="sm" p="xs" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<Box style={{ minHeight: 0, overflow: "hidden" }}>
|
||||
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
|
||||
</Box>
|
||||
|
||||
@@ -55,7 +55,8 @@ export const SystemHealthMonitoring = ({
|
||||
integrationIds,
|
||||
width,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"healthMonitoring">) => {
|
||||
withScrollArea = true,
|
||||
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
|
||||
const t = useI18n();
|
||||
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
|
||||
integrationIds,
|
||||
@@ -69,7 +70,7 @@ export const SystemHealthMonitoring = ({
|
||||
if (healthData.length === 0) return <WidgetEmptyState />;
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<ScrollArea h={withScrollArea ? "100%" : undefined}>
|
||||
<Stack mih="100%" gap="sm" className="health-monitoring">
|
||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
||||
const filteredFileSystem = filterStorageVolumes(
|
||||
|
||||
@@ -74,7 +74,12 @@ export type {
|
||||
WidgetQueryMatcher,
|
||||
WidgetQueryMatcherScope,
|
||||
} from "./definition";
|
||||
export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition";
|
||||
export {
|
||||
getWidgetQueryKeys,
|
||||
getWidgetRuntimeQueries,
|
||||
normalizeWidgetQuery,
|
||||
widgetQueryValueEquals,
|
||||
} from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
@@ -27,6 +14,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "../search-button.module.css";
|
||||
@@ -44,7 +32,7 @@ export default function MediaServerWidget({
|
||||
.filter(({ id }) => integrationIds.includes(id))
|
||||
.map(({ id }) => id),
|
||||
);
|
||||
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
const { data: mediaRequestData } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
|
||||
integrationIds,
|
||||
statuses:
|
||||
options.statusFilter.length > 0
|
||||
@@ -53,16 +41,22 @@ export default function MediaServerWidget({
|
||||
recentDays: options.recentDays,
|
||||
});
|
||||
|
||||
if (!mediaRequests) return <WidgetEmptyState />;
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
if (!mediaRequestData) return <WidgetEmptyState />;
|
||||
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
|
||||
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
<Box className={classes.searchRoot}>
|
||||
<Stack className={classes.searchRoot} gap={0}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
{failedIntegrations.length > 0 && (
|
||||
<Group px="sm" pt="xs">
|
||||
<IntegrationErrorIndicator results={failedIntegrations} />
|
||||
</Group>
|
||||
)}
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
@@ -77,7 +71,7 @@ export default function MediaServerWidget({
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,7 +94,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
|
||||
};
|
||||
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
|
||||
isTiny: boolean;
|
||||
isAdvanced: boolean;
|
||||
canInteract: boolean;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../../common/empty-state";
|
||||
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
import { NoIntegrationDataError } from "../../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
@@ -45,7 +46,12 @@ export default function MediaServerWidget({
|
||||
const board = useRequiredBoard();
|
||||
|
||||
if (!requestStats) return <WidgetEmptyState />;
|
||||
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
|
||||
if (
|
||||
requestStats.users.length === 0 &&
|
||||
requestStats.stats.length === 0 &&
|
||||
requestStats.failedIntegrations.length === 0
|
||||
)
|
||||
throw new NoIntegrationDataError();
|
||||
|
||||
const data = [
|
||||
{
|
||||
@@ -99,6 +105,9 @@ export default function MediaServerWidget({
|
||||
|
||||
return (
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
<Box pos="absolute" top="xs" left="xs" style={{ zIndex: 2 }}>
|
||||
<IntegrationErrorIndicator results={requestStats.failedIntegrations} />
|
||||
</Box>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
|
||||
|
||||
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["metadata"]>["transcoding"];
|
||||
|
||||
@@ -305,6 +306,7 @@ export default function MediaServerWidget({
|
||||
0,
|
||||
);
|
||||
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
|
||||
const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" display="flex">
|
||||
@@ -317,6 +319,14 @@ export default function MediaServerWidget({
|
||||
<Badge variant="light" color="orange">
|
||||
{t("summary.transcoding", { count: transcodingCount })}
|
||||
</Badge>
|
||||
<Group ml="auto">
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
{!isAdvanced && hasFailedIntegrations && (
|
||||
<Group h={18} px="xs" justify="flex-end">
|
||||
<IntegrationErrorIndicator results={currentStreams} />
|
||||
</Group>
|
||||
)}
|
||||
<MantineReactTable table={table} />
|
||||
|
||||
@@ -146,48 +146,61 @@ function TraefikWidgetContent({
|
||||
(left, right) =>
|
||||
resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name),
|
||||
);
|
||||
return (
|
||||
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%" p="md">
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
const resourceList = (
|
||||
<Stack gap="xs">
|
||||
{combined.failedEndpoints.map((endpoint) => (
|
||||
<Badge key={endpoint} color="red" variant="light">
|
||||
{t("failedEndpoint", { endpoint })}
|
||||
</Badge>
|
||||
))}
|
||||
{resources.map((resource, index) => (
|
||||
<Group
|
||||
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{resource.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
color={
|
||||
resource.status === "error"
|
||||
? "red"
|
||||
: resource.status === "warning"
|
||||
? "yellow"
|
||||
: resource.status === "enabled"
|
||||
? "green"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
{t(`resourceStatus.${resource.status}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (width < 900) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs">
|
||||
{combined.failedEndpoints.map((endpoint) => (
|
||||
<Badge key={endpoint} color="red" variant="light">
|
||||
{t("failedEndpoint", { endpoint })}
|
||||
</Badge>
|
||||
))}
|
||||
{resources.map((resource, index) => (
|
||||
<Group
|
||||
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{resource.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge
|
||||
color={
|
||||
resource.status === "error"
|
||||
? "red"
|
||||
: resource.status === "warning"
|
||||
? "yellow"
|
||||
: resource.status === "enabled"
|
||||
? "green"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
{t(`resourceStatus.${resource.status}`)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
<Stack gap="md" p="md">
|
||||
{summary}
|
||||
{resourceList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<ScrollArea h="100%">{summary}</ScrollArea>
|
||||
<ScrollArea h="100%">{resourceList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function UmamiWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode = "compact",
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"umami">) {
|
||||
@@ -75,6 +76,7 @@ export default function UmamiWidget({
|
||||
viewMode={options.viewMode}
|
||||
topCount={options.topCount}
|
||||
width={width}
|
||||
height={height}
|
||||
displayMode={displayMode}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, LineChart } from "@mantine/charts";
|
||||
import { Box, Group, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { formatDuration } from "@homarr/common";
|
||||
@@ -24,6 +24,7 @@ interface UmamiContentProps {
|
||||
viewMode: string;
|
||||
topCount: number;
|
||||
width: number;
|
||||
height: number;
|
||||
displayMode: "compact" | "advanced";
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ export function UmamiContent({
|
||||
viewMode,
|
||||
topCount,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: UmamiContentProps) {
|
||||
const t = useScopedI18n("widget.umami");
|
||||
@@ -149,6 +151,44 @@ export function UmamiContent({
|
||||
/>
|
||||
);
|
||||
|
||||
const advancedLists = (
|
||||
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
|
||||
{viewMode !== "topPages" && (
|
||||
<UmamiTopPagesContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
limit={topCount}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "topReferrers" && (
|
||||
<UmamiTopReferrersContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
limit={topCount}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const advancedContent =
|
||||
width < 900 ? (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="md">
|
||||
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
|
||||
{advancedLists}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
<Box h="100%" mih={260}>
|
||||
{selectedView}
|
||||
</Box>
|
||||
{advancedLists}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={4} p="xs" h="100%">
|
||||
<Group justify="space-between" align="baseline" wrap="nowrap">
|
||||
@@ -213,31 +253,7 @@ export function UmamiContent({
|
||||
)}
|
||||
</Group>
|
||||
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
|
||||
{displayMode === "advanced" ? (
|
||||
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%">
|
||||
<Box mih={260}>{selectedView}</Box>
|
||||
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
|
||||
{viewMode !== "topPages" && (
|
||||
<UmamiTopPagesContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
limit={topCount}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "topReferrers" && (
|
||||
<UmamiTopReferrersContent
|
||||
integrationIds={integrationIds}
|
||||
websiteId={websiteId}
|
||||
timeFrame={timeFrame}
|
||||
limit={topCount}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
selectedView
|
||||
)}
|
||||
{displayMode === "advanced" ? advancedContent : selectedView}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Badge, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Box, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -213,33 +213,46 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
|
||||
|
||||
if (displayMode === "compact") return summaryContent;
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="lg" h="100%" p="md">
|
||||
{summaryContent}
|
||||
const monitorList = (
|
||||
<Stack gap="xs">
|
||||
{monitors
|
||||
.toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
|
||||
.map((monitor) => (
|
||||
<Group key={monitor.key} justify="space-between" wrap="nowrap" p="xs">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{monitor.name}
|
||||
</Text>
|
||||
{dashboardData.length > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{monitor.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge color={monitor.status === "up" ? "green" : monitor.status === "down" ? "red" : "yellow"}>
|
||||
{t(`status.${monitor.status}`)} ·{" "}
|
||||
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (width < 760) {
|
||||
return (
|
||||
<ScrollArea h="100%">
|
||||
<Stack gap="xs">
|
||||
{monitors
|
||||
.toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
|
||||
.map((monitor) => (
|
||||
<Group key={monitor.key} justify="space-between" wrap="nowrap" p="xs">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{monitor.name}
|
||||
</Text>
|
||||
{dashboardData.length > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{monitor.integrationName}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge color={monitor.status === "up" ? "green" : monitor.status === "down" ? "red" : "yellow"}>
|
||||
{t(`status.${monitor.status}`)} ·{" "}
|
||||
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
<Stack gap="lg" p="md">
|
||||
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
|
||||
{monitorList}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
|
||||
{summaryContent}
|
||||
<ScrollArea h="100%">{monitorList}</ScrollArea>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user