fix: close final widget review gaps

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent c67c893f8f
commit 2efa6bcd4c
4 files changed
+108 -54

No files matched your search

@@ -0,0 +1,29 @@
import dayjs from "dayjs";
import frenchLocale from "dayjs/locale/fr";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils";
describe("Coolify time formatting", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-02T12:00:00Z"));
dayjs.locale(frenchLocale);
});
afterEach(() => {
dayjs.locale("en");
vi.useRealTimers();
});
it("uses the active Day.js locale for past and future timestamps", () => {
expect(formatRelativeTime("2026-08-02T11:00:00Z")).toBe("il y a une heure");
expect(formatRelativeTime("2026-08-02T13:00:00Z")).toBe("dans une heure");
});
it("omits missing, invalid, and currently running timestamps", () => {
expect(formatRelativeTime(undefined)).toBeUndefined();
expect(formatRelativeTime("invalid")).toBeUndefined();
expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined();
});
});
+8 -21
View File
@@ -1,9 +1,14 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type {
CoolifyApplicationWithContext,
CoolifyServer,
CoolifyServiceWithContext,
} from "@homarr/integrations/types";
dayjs.extend(relativeTime);
export function parseStatus(status: string): string {
const firstPart = status.split(":")[0]?.toLowerCase();
if (!firstPart) return "unknown";
@@ -42,26 +47,8 @@ export function getBadgeColor(running: number, total: number): string {
export function formatRelativeTime(dateString: string | null | undefined): string | undefined {
if (!dateString) return undefined;
const date = new Date(dateString);
if (isNaN(date.getTime())) return undefined;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffDay > 0) {
const remainingHours = diffHour % 24;
return remainingHours > 0 ? `${diffDay}d ${remainingHours}h ago` : `${diffDay}d ago`;
}
if (diffHour > 0) {
const remainingMin = diffMin % 60;
return remainingMin > 0 ? `${diffHour}h ${remainingMin}m ago` : `${diffHour}h ago`;
}
if (diffMin > 0) return `${diffMin}m ago`;
return "just now";
const date = dayjs(dateString);
return date.isValid() ? date.fromNow() : undefined;
}
export function getResourceTimestamp(
@@ -116,7 +103,7 @@ export function buildServerResourceCounts(
}
export function createWidgetKey(integrationIds: string[]): string {
return integrationIds.slice().sort().join("-");
return integrationIds.toSorted().join("-");
}
export function createStorageKey(widgetKey: string, integrationId: string, type: "sections" | "show-ip"): string {
+42 -10
View File
@@ -1,7 +1,14 @@
"use client";
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconFileText, IconLink } from "@tabler/icons-react";
import { ActionIcon, Anchor, Group, Stack, Text } from "@mantine/core";
import {
IconCircleCheckFilled,
IconCircleXFilled,
IconFileText,
IconHelpCircleFilled,
IconLink,
IconLoader2,
} from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
@@ -30,6 +37,27 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
const t = useScopedI18n("widget.coolify");
const status = parseStatus(item.status ?? "");
const statusColor = getStatusColor(status);
const isTransitioning = status === "starting" || status === "restarting";
const StatusIcon =
status === "running"
? IconCircleCheckFilled
: isTransitioning
? IconLoader2
: status === "unknown"
? IconHelpCircleFilled
: IconCircleXFilled;
const statusLabel =
status === "running"
? t("status.running")
: status === "stopped"
? t("status.stopped")
: status === "exited"
? t("status.exited")
: status === "starting"
? t("status.starting")
: status === "restarting"
? t("status.restarting")
: t("status.unknown");
const resourceUrl =
item.projectUuid && item.environmentUuid
@@ -37,34 +65,38 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
: undefined;
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
const publicUrl = cleanFqdn(item.fqdn);
const resourceTimestamp = getResourceTimestamp(item, resourceType);
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={statusColor} />
<StatusIcon aria-label={statusLabel} size={isTiny ? 12 : 16} color={`var(--mantine-color-${statusColor}-6)`} />
{resourceUrl ? (
<Anchor
className={actionTargetClasses.root}
href={resourceUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "8px" : "xs"}
fz="xs"
c="inherit"
lineClamp={1}
truncate="end"
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
>
{item.name}
</Anchor>
) : (
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
<Text lineClamp={1} fz="xs">
{item.name}
</Text>
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
{cleanFqdn(item.fqdn) && (
{publicUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={cleanFqdn(item.fqdn)}
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={t("action.openResource", { name: item.name })}
@@ -95,9 +127,9 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
)}
{!isTiny && getResourceTimestamp(item, resourceType) && (
{!isTiny && resourceTimestamp && (
<Text fz="10px" c="dimmed" ml="auto">
{getResourceTimestamp(item, resourceType)}
{resourceTimestamp}
</Text>
)}
</Group>
@@ -1,7 +1,7 @@
"use client";
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
@@ -32,29 +32,28 @@ export function ServersSection({
return (
<Accordion.Item value="servers">
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />}>
<Group gap="xs" justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
<Group gap={0} wrap="nowrap">
<Accordion.Control icon={isTiny ? null : <IconServer size={16} />} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs">
<Text size="xs">{tCommon("servers")}</Text>
<Badge variant="dot" color={getBadgeColor(onlineServers, servers.length)} size="xs">
{onlineServers} / {servers.length}
</Badge>
</Group>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t(showIp ? "action.hideIp" : "action.showIp")}
size="xs"
variant="subtle"
c="dimmed"
onClick={(event) => {
event.stopPropagation();
onToggleIp();
}}
>
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
</ActionIcon>
</Group>
</Accordion.Control>
</Accordion.Control>
<ActionIcon
className={actionTargetClasses.root}
aria-label={t(showIp ? "action.hideIp" : "action.showIp")}
aria-pressed={showIp}
size="xs"
variant="subtle"
c="dimmed"
mr="xs"
onClick={onToggleIp}
>
{showIp ? <IconEye size={12} /> : <IconEyeOff size={12} />}
</ActionIcon>
</Group>
<Accordion.Panel p={4}>
{servers.length > 0 ? (
<Stack gap={4}>
@@ -94,18 +93,25 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={isOnline ? "green" : "red"} />
<StatusIcon
aria-label={t(isOnline ? "status.online" : "status.offline")}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
href={serverUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "8px" : "xs"}
fz="xs"
c="inherit"
lineClamp={1}
truncate="end"
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
>
{server.name}
</Anchor>
@@ -115,7 +121,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
</Badge>
) : (
<Text fz="10px" c="dimmed">
({counts.apps} apps / {counts.services} svcs)
({t("server.resourceCounts", { applications: counts.apps, services: counts.services })})
</Text>
)}
</Group>