feat: expand advanced widget experiences
This commit is contained in:
1 parent
9437d211c8
commit
c099a8eef6
119 files changed
+3624
-844
No files matched your search
@@ -1,4 +1,5 @@
|
||||
.root {
|
||||
position: relative;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9,6 +10,32 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advancedContent {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.advancedContent .statsSection {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.advancedContent .grid {
|
||||
height: auto;
|
||||
grid-auto-rows: minmax(92px, auto);
|
||||
}
|
||||
|
||||
.advancedContent .osSectionWrapper {
|
||||
flex: none;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.queryIndicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.compactRoot {
|
||||
padding: clamp(4px, 3cqi, 8px);
|
||||
gap: 2px;
|
||||
|
||||
@@ -20,26 +20,17 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
import { getGridCols, shouldShowComplianceHeroText } from "./layout-utils";
|
||||
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "./layout-utils";
|
||||
import { OsDistributionSection } from "./os-distribution-section";
|
||||
import type { PatchMonStatKey } from "./stat-colors";
|
||||
import { resolveStatColor, severityToIconColor, severityToMantineBgColor, severityToMantineColor } from "./stat-colors";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const statVisibilityByOption = {
|
||||
showTotalHosts: "totalHosts",
|
||||
showHostsNeedingUpdates: "hostsNeedingUpdates",
|
||||
showSecurityUpdates: "securityUpdates",
|
||||
showUpToDateHosts: "upToDateHosts",
|
||||
showHostsWithSecurityUpdates: "hostsWithSecurityUpdates",
|
||||
showRecentUpdates24h: "recentUpdates24h",
|
||||
showTotalOutdatedPackages: "totalOutdatedPackages",
|
||||
showTotalRepos: "totalRepos",
|
||||
} as const;
|
||||
|
||||
const statIcons: Record<PatchMonStatKey, TablerIcon> = {
|
||||
totalHosts: IconServer,
|
||||
hostsNeedingUpdates: IconRefreshAlert,
|
||||
@@ -75,15 +66,19 @@ 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,
|
||||
}: WidgetComponentProps<"patchmon">) {
|
||||
const t = useScopedI18n("widget.patchmon");
|
||||
const integrationId = integrationIds[0] ?? "";
|
||||
const { data: stats, error } = clientApi.widget.patchmon.getStats.useQuery(
|
||||
{ integrationId },
|
||||
{ staleTime: 60 * 1000 },
|
||||
);
|
||||
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
|
||||
const stats = getUsableWidgetQueryData(statsQuery);
|
||||
|
||||
if (error && !stats) throw error;
|
||||
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!stats) return <WidgetEmptyState />;
|
||||
|
||||
const statValues: Record<PatchMonStatKey, number> = {
|
||||
@@ -97,24 +92,24 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
totalRepos: stats.totalRepos,
|
||||
};
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const visibleStatKeys = getVisiblePatchMonStatKeys(options, isAdvanced);
|
||||
|
||||
const colorContext = { totalHosts: stats.totalHosts };
|
||||
const layout = getLayoutMode(width, height);
|
||||
const layout = isAdvanced ? "spacious" : getLayoutMode(width, height);
|
||||
const isLandscape = width / height > 2.5;
|
||||
const isTinySquare = width < 160 && height < 160;
|
||||
const isShortLandscape = width >= 160 && height < 160;
|
||||
const isCompactSurface = isTinySquare || isShortLandscape;
|
||||
const isCompactSurface = !isAdvanced && (isTinySquare || isShortLandscape);
|
||||
const isNarrow = width < 180;
|
||||
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
|
||||
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
|
||||
const showOsData = (isAdvanced || 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 showHeroText = showHero && shouldShowComplianceHeroText(width);
|
||||
const showIcons = isAdvanced || layout !== "mini";
|
||||
const showLabels = isAdvanced || layout !== "mini";
|
||||
const showHero =
|
||||
isAdvanced || (options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90);
|
||||
const showHeroText = showHero && (isAdvanced || shouldShowComplianceHeroText(width));
|
||||
const showHeroRingOnly = showHero && !showHeroText;
|
||||
const compactPrimaryContent = isCompactSurface
|
||||
? getCompactPrimaryContent({
|
||||
@@ -124,16 +119,18 @@ 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 && (isAdvanced || height > 200);
|
||||
const showCompactFooter = isCompactSurface && height >= 110;
|
||||
const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
|
||||
const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
|
||||
const showFooter = isAdvanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
|
||||
const showOsLegend = osDisplayMode === "donut" && (isAdvanced || (width >= 380 && height >= 300));
|
||||
|
||||
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
|
||||
|
||||
const gridCols = isLandscape
|
||||
? Math.min(visibleStatKeys.length, 4)
|
||||
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
|
||||
const gridCols = isAdvanced
|
||||
? Math.min(visibleStatKeys.length, width >= 500 ? 4 : width >= 220 ? 2 : 1)
|
||||
: isLandscape
|
||||
? Math.min(visibleStatKeys.length, 4)
|
||||
: Math.min(getGridCols(width), visibleStatKeys.length || 1);
|
||||
const iconSize = getIconSize(width);
|
||||
const ringSize = getRingSize(width, height, showHeroRingOnly);
|
||||
|
||||
@@ -266,8 +263,8 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
<div className={`${classes.osSectionWrapper} ${visibleStatKeys.length > 0 ? classes.osSectionWithStats : ""}`}>
|
||||
<OsDistributionSection
|
||||
entries={stats.osDistribution}
|
||||
limit={Number(options.osDistributionLimit)}
|
||||
showOsVersion={options.showOsVersion}
|
||||
limit={isAdvanced ? 0 : Number(options.osDistributionLimit)}
|
||||
showOsVersion={isAdvanced || options.showOsVersion}
|
||||
displayMode={osDisplayMode}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -276,10 +273,13 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
</div>
|
||||
);
|
||||
|
||||
const needsScroll = layout === "mini" || layout === "compact";
|
||||
const needsScroll = isAdvanced || layout === "mini" || layout === "compact";
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.queryIndicator}>
|
||||
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
|
||||
</div>
|
||||
{showHero && (
|
||||
<ComplianceHero
|
||||
compliancePercent={compliancePercent}
|
||||
@@ -296,7 +296,7 @@ export default function PatchMonWidget({ integrationIds, options, width, height
|
||||
{(statGridContent || osContent) &&
|
||||
(needsScroll ? (
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }} scrollbars="y">
|
||||
<div className={classes.content}>
|
||||
<div className={`${classes.content} ${isAdvanced ? classes.advancedContent : ""}`}>
|
||||
{statGridContent}
|
||||
{osContent}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors";
|
||||
import { patchmonOptionsSuperRefine } from "./threshold-validation";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
|
||||
supportsAdvancedFocus: false,
|
||||
supportsAdvancedFocus: true,
|
||||
icon: IconShieldCheck,
|
||||
supportedIntegrations: ["patchmon"],
|
||||
integrationsRequired: true,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PatchMonStatKey } from "./stat-colors";
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 500, cols: 4 },
|
||||
{ minWidth: 380, cols: 3 },
|
||||
@@ -5,6 +7,26 @@ const gridColsByWidth = [
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
|
||||
const statVisibilityByOption = [
|
||||
["showTotalHosts", "totalHosts"],
|
||||
["showHostsNeedingUpdates", "hostsNeedingUpdates"],
|
||||
["showSecurityUpdates", "securityUpdates"],
|
||||
["showUpToDateHosts", "upToDateHosts"],
|
||||
["showHostsWithSecurityUpdates", "hostsWithSecurityUpdates"],
|
||||
["showRecentUpdates24h", "recentUpdates24h"],
|
||||
["showTotalOutdatedPackages", "totalOutdatedPackages"],
|
||||
["showTotalRepos", "totalRepos"],
|
||||
] as const satisfies readonly (readonly [string, PatchMonStatKey])[];
|
||||
|
||||
type PatchMonStatVisibilityOptions = Partial<Record<(typeof statVisibilityByOption)[number][0], boolean>>;
|
||||
|
||||
export function getVisiblePatchMonStatKeys(
|
||||
options: PatchMonStatVisibilityOptions,
|
||||
isAdvanced: boolean,
|
||||
): PatchMonStatKey[] {
|
||||
return statVisibilityByOption.filter(([optionKey]) => isAdvanced || options[optionKey]).map(([, statKey]) => statKey);
|
||||
}
|
||||
|
||||
export function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getGridCols, shouldShowComplianceHeroText } from "../layout-utils";
|
||||
import { getGridCols, getVisiblePatchMonStatKeys, shouldShowComplianceHeroText } from "../layout-utils";
|
||||
|
||||
describe("getGridCols", () => {
|
||||
test("returns 1 column for narrow widths", () => {
|
||||
@@ -34,3 +34,31 @@ describe("shouldShowComplianceHeroText", () => {
|
||||
expect(shouldShowComplianceHeroText(384)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisiblePatchMonStatKeys", () => {
|
||||
test("keeps compact visibility choices", () => {
|
||||
expect(
|
||||
getVisiblePatchMonStatKeys(
|
||||
{
|
||||
showTotalHosts: true,
|
||||
showSecurityUpdates: true,
|
||||
showTotalRepos: false,
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toEqual(["totalHosts", "securityUpdates"]);
|
||||
});
|
||||
|
||||
test("returns every owned statistic in advanced mode", () => {
|
||||
expect(getVisiblePatchMonStatKeys({}, true)).toEqual([
|
||||
"totalHosts",
|
||||
"hostsNeedingUpdates",
|
||||
"securityUpdates",
|
||||
"upToDateHosts",
|
||||
"hostsWithSecurityUpdates",
|
||||
"recentUpdates24h",
|
||||
"totalOutdatedPackages",
|
||||
"totalRepos",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user