fix: close final widget review gaps
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user