feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -4,7 +4,7 @@ import { Accordion, Badge, Group, Stack, Text } from "@mantine/core";
import { IconCloud } from "@tabler/icons-react";
import type { CoolifyApplicationWithContext } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { getBadgeColor, parseStatus } from "./coolify-utils";
import { ResourceRow } from "./resource-row";
@@ -16,13 +16,13 @@ interface ApplicationsSectionProps {
}
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
return (
<Accordion.Item value="applications">
<Accordion.Control icon={isTiny ? null : <IconCloud size={16} />}>
<Accordion.Control icon={isTiny ? null : <IconCloud size="var(--mantine-font-size-md)" />}>
<Group gap="xs">
<Text size="xs">{tCommon("applications")}</Text>
<Badge variant="dot" color={getBadgeColor(runningApps, applications.length)} size="xs">
@@ -0,0 +1,25 @@
.accordion {
--coolify-neutral-fill: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12));
--coolify-neutral-border: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.accordion :global(.mantine-Accordion-item) {
--item-border-color: var(--coolify-neutral-border);
--item-filled-color: var(--coolify-neutral-fill);
}
.accordion :global(.mantine-Accordion-control) {
background-color: transparent;
}
.accordion :global(.mantine-Accordion-control:not(:disabled, [data-disabled]):hover) {
background-color: var(--coolify-neutral-fill);
}
.neutralDividerBottom {
border-bottom: 1px solid var(--coolify-neutral-border);
}
.neutralDividerTop {
border-top: 1px solid var(--coolify-neutral-border);
}
+110 -24
View File
@@ -1,17 +1,25 @@
"use client";
import { ScrollArea, Stack, Text } from "@mantine/core";
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { createWidgetKey } from "./coolify-utils";
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
import type { InstanceData } from "./types";
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
export default function CoolifyWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useI18n("widget.coolify");
if (integrationIds.length === 0) {
return (
@@ -21,39 +29,117 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
const isAdvanced = displayMode === "advanced";
const sectionVisibility = getCoolifySectionVisibility(options, displayMode);
return (
<CoolifyContent
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
isAdvanced={isAdvanced}
/>
);
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const t = useI18n("common");
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
});
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
const { isPending } = instancesQuery;
const successfulInstances = instancesData.filter(
(instance): instance is InstanceData => instance.instanceInfo !== null,
);
const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null);
const isTiny = width < 256;
const [firstInstance] = instancesData;
const [firstInstance] = successfulInstances;
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
if (isPending) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("action.loading")}
</Text>
</Stack>
);
}
if (successfulInstances.length === 0) {
return (
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<Box style={{ minHeight: 0, flex: 1 }}>
<WidgetEmptyState />
</Box>
</Stack>
);
}
if (successfulInstances.length === 1 && firstInstance) {
return (
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<div style={{ minHeight: 0, flex: 1 }}>
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
/>
</div>
</Stack>
);
}
return (
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
/>
))}
</Stack>
</ScrollArea>
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<ScrollArea style={{ minHeight: 0, flex: 1 }}>
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{successfulInstances.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
/>
))}
</SimpleGrid>
</ScrollArea>
</Stack>
);
}
const FailedSourceBadges = ({
sources,
queryError,
}: {
sources: { integrationId: string; integrationName: string }[];
queryError: unknown;
}) => {
const t = useI18n("widget.coolify");
if (sources.length === 0 && !queryError) return null;
return (
<Group gap={4} p="xs" pb={0} wrap="wrap">
{sources.map((source) => (
<Badge key={source.integrationId} color="red" variant="light" size="xs">
{t("source.unavailable", { source: source.integrationName })}
</Badge>
))}
</Group>
);
};
@@ -0,0 +1,83 @@
import dayjs from "dayjs";
import frenchLocale from "dayjs/locale/fr";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatRelativeTime,
getCoolifySectionVisibility,
getCoolifyServerState,
getResourceTimestamp,
isCoolifyServerOnline,
} 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();
});
});
describe("Coolify advanced display", () => {
it("forces every non-sensitive section without changing compact settings", () => {
const options = { showServers: false, showApplications: true, showServices: false };
expect(getCoolifySectionVisibility(options, "compact")).toEqual(options);
expect(getCoolifySectionVisibility(options, "advanced")).toEqual({
showServers: true,
showApplications: true,
showServices: true,
});
});
it("uses top-level server state before the settings fallback", () => {
const server = {
id: null,
uuid: "server-1",
name: "Build server",
ip: null,
is_reachable: false,
is_usable: null,
settings: {
server_id: null,
is_build_server: null,
is_reachable: true,
is_usable: true,
},
};
expect(getCoolifyServerState(server, "is_reachable")).toBe(false);
expect(getCoolifyServerState(server, "is_usable")).toBe(true);
});
it("does not count unknown reachability as online", () => {
const server = {
id: null,
uuid: "server-unknown",
name: "Unknown server",
ip: null,
is_reachable: null,
is_usable: null,
settings: null,
};
expect(getCoolifyServerState(server, "is_reachable")).toBeUndefined();
expect(isCoolifyServerOnline(server)).toBe(false);
});
});
+31 -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,10 +103,33 @@ 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 {
const cardKey = widgetKey.includes("-") ? `${widgetKey}-${integrationId}` : widgetKey;
return `coolify-${type}-${cardKey}`;
}
interface CoolifySectionOptions {
showServers: boolean;
showApplications: boolean;
showServices: boolean;
}
export function getCoolifySectionVisibility(
options: CoolifySectionOptions,
displayMode?: "compact" | "advanced",
): CoolifySectionOptions {
if (displayMode === "advanced") {
return { showServers: true, showApplications: true, showServices: true };
}
return options;
}
export function getCoolifyServerState(server: CoolifyServer, field: "is_reachable" | "is_usable"): boolean | undefined {
return server[field] ?? server.settings?.[field] ?? undefined;
}
export const isCoolifyServerOnline = (server: CoolifyServer): boolean =>
getCoolifyServerState(server, "is_reachable") === true;
+4 -1
View File
@@ -1,9 +1,12 @@
import { IconCloud, IconServerOff } from "@tabler/icons-react";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: true,
icon: IconCloud,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -18,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify",
}),
}));
},
supportedIntegrations: ["coolify"],
...getWidgetIntegrationConfig("coolify"),
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
+67 -31
View File
@@ -1,13 +1,16 @@
"use client";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
import classes from "./component.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
import type { CoolifyOptions, InstanceData } from "./types";
@@ -17,11 +20,12 @@ interface InstanceCardProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
}
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey }: InstanceCardProps) {
const t = useI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -31,17 +35,16 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
const runningApps = instance.instanceInfo.applications.filter(
(a) => parseStatus(a.status ?? "") === "running",
).length;
@@ -50,18 +53,29 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
).length;
return (
<Card p={0} radius="sm">
<Group
p="xs"
justify="space-between"
wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
>
<Group gap={4} wrap="nowrap">
<Card p={0} radius="sm" bg="transparent">
<Group p="xs" justify="space-between" wrap="nowrap" className={classes.neutralDividerBottom}>
<Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "10px" : "xs"} fw={600} c="inherit" lineClamp={1}>
{instance.integrationName}
</Anchor>
<Stack gap={0} miw={0}>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
{isAdvanced && (
<Text fz="10px" c="dimmed" truncate="end">
{t("source.url", { url: displayUrl })}
</Text>
)}
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{options.showServers && (
@@ -90,13 +104,22 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
</Group>
</Group>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
<Accordion
className={classes.accordion}
variant="filled"
chevronPosition="right"
multiple
keepMounted={false}
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -105,18 +128,31 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
)}
</Accordion>
<Group justify="space-between" p={4} style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Card>
);
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
<Text size="10px" c="dimmed">
v{version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
);
};
+85 -20
View File
@@ -1,8 +1,20 @@
"use client";
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, 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 { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -24,52 +36,105 @@ interface ResourceRowProps {
}
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
const t = useI18n("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 =
const resourceUrl = getSafeApplicationUrl(
item.projectUuid && item.environmentUuid
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
: undefined;
: undefined,
);
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
const publicUrl = getSafeApplicationUrl(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}
style={zoomCompensatedSize(isTiny ? 12 : 16)}
color={`var(--mantine-color-${statusColor}-6)`}
/>
{resourceUrl ? (
<Anchor href={resourceUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
<Anchor
className={actionTargetClasses.root}
href={resourceUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
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) && (
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconLink size={12} />
</ActionIcon>
)}
{resourceUrl && (
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
{publicUrl && (
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={publicUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openResource", { name: item.name })}
size="xs"
variant="subtle"
c="dimmed"
>
<IconLink size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
{logsUrl && (
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconFileText size={12} />
<ActionIcon
className={actionTargetClasses.root}
component="a"
href={logsUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openLogs", { name: item.name })}
size="xs"
variant="subtle"
c="dimmed"
>
<IconFileText size="var(--mantine-font-size-xs)" />
</ActionIcon>
)}
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
{getResourceTimestamp(item, resourceType) && (
{resourceTimestamp && (
<Text fz="10px" c="dimmed" ml="auto">
{getResourceTimestamp(item, resourceType)}
{resourceTimestamp}
</Text>
)}
</Group>
@@ -1,18 +1,28 @@
"use client";
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import {
IconCircleCheckFilled,
IconCircleFilled,
IconCircleXFilled,
IconEye,
IconEyeOff,
IconServer,
} from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { getBadgeColor } from "./coolify-utils";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getBadgeColor, getCoolifyServerState, isCoolifyServerOnline } from "./coolify-utils";
interface ServersSectionProps {
servers: CoolifyServer[];
serverResourceCounts: Map<number, { apps: number; services: number }>;
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
showIp: boolean;
onToggleIp: () => void;
}
@@ -22,36 +32,41 @@ export function ServersSection({
serverResourceCounts,
baseUrl,
isTiny,
isAdvanced,
showIp,
onToggleIp,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const onlineServers = servers.filter((server) => server.is_reachable !== false).length;
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const onlineServers = servers.filter(isCoolifyServerOnline).length;
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="var(--mantine-font-size-md)" />}
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
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="var(--mantine-font-size-xs)" /> : <IconEyeOff size="var(--mantine-font-size-xs)" />}
</ActionIcon>
</Group>
<Accordion.Panel p={4}>
{servers.length > 0 ? (
<Stack gap={4}>
@@ -64,6 +79,7 @@ export function ServersSection({
}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
/>
))}
@@ -83,20 +99,40 @@ interface ServerRowProps {
counts: { apps: number; services: number };
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
showIp: boolean;
}
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) {
const t = useI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
const reachability = getCoolifyServerState(server, "is_reachable");
const usability = getCoolifyServerState(server, "is_usable");
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
const status = reachability === true ? "online" : reachability === false ? "offline" : "unknown";
const StatusIcon =
reachability === true ? IconCircleCheckFilled : reachability === false ? IconCircleXFilled : IconCircleFilled;
const statusColor = reachability === true ? "green" : reachability === false ? "red" : "gray";
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={isOnline ? "green" : "red"} />
<Anchor href={serverUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
<StatusIcon
aria-label={t(`status.${status}`)}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${statusColor}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
component={serverUrl ? "a" : "span"}
href={serverUrl}
target={serverUrl ? "_blank" : undefined}
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
c="inherit"
truncate="end"
style={{ display: "inline-flex", alignItems: "center", overflow: "hidden" }}
>
{server.name}
</Anchor>
{isBuildServer ? (
@@ -105,18 +141,36 @@ 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>
<Group wrap="nowrap" gap={4} ml={16}>
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
<Text fz="10px" c="dimmed">
{showIp ? server.ip : "***.***.***.***"}
{showIp ? (server.ip ?? "—") : "***.***.***.***"}
</Text>
</Group>
{isAdvanced && (
<Group gap={4} ml={16} wrap="wrap">
<ServerStateBadge field="reachability" value={reachability} />
<ServerStateBadge field="usability" value={usability} />
</Group>
)}
</Stack>
);
}
const ServerStateBadge = ({ field, value }: { field: "reachability" | "usability"; value: boolean | undefined }) => {
const t = useI18n("widget.coolify");
const status =
value === undefined
? t("status.unknown")
: field === "reachability"
? t(value ? "status.reachable" : "status.unreachable")
: t(value ? "status.usable" : "status.unusable");
return (
<Badge size="xs" variant="light" color={value === true ? "green" : value === false ? "red" : "gray"}>
{t(`server.${field}`)}: {status}
</Badge>
);
};
@@ -1,28 +1,31 @@
"use client";
import { Accordion, Badge, Group, Stack, Text } from "@mantine/core";
import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import { IconStack2 } from "@tabler/icons-react";
import type { CoolifyServiceWithContext } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { getBadgeColor, parseStatus } from "./coolify-utils";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { cleanFqdn, getBadgeColor, getStatusColor, parseStatus } from "./coolify-utils";
import { ResourceRow } from "./resource-row";
interface ServicesSectionProps {
services: CoolifyServiceWithContext[];
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
}
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: ServicesSectionProps) {
const t = useI18n("widget.coolify");
const tCommon = useI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
return (
<Accordion.Item value="services">
<Accordion.Control icon={isTiny ? null : <IconStack2 size={16} />}>
<Accordion.Control icon={isTiny ? null : <IconStack2 size="var(--mantine-font-size-md)" />}>
<Group gap="xs">
<Text size="xs">{tCommon("services")}</Text>
<Badge variant="dot" color={getBadgeColor(runningServices, services.length)} size="xs">
@@ -34,7 +37,19 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr
{services.length > 0 ? (
<Stack gap={4}>
{services.map((service) => (
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
<Stack key={service.uuid} gap={2}>
<ResourceRow item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
{isAdvanced && service.applications && service.applications.length > 0 && (
<Stack gap={2} ml="lg">
<Text fz="10px" c="dimmed">
{tCommon("applications")}
</Text>
{service.applications.map((application) => (
<ServiceApplicationRow key={application.uuid} application={application} />
))}
</Stack>
)}
</Stack>
))}
</Stack>
) : (
@@ -46,3 +61,45 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr
</Accordion.Item>
);
}
type ServiceApplication = NonNullable<CoolifyServiceWithContext["applications"]>[number];
const ServiceApplicationRow = ({ application }: { application: ServiceApplication }) => {
const t = useI18n("widget.coolify");
const status = parseStatus(application.status ?? "");
const publicUrl = getSafeApplicationUrl(cleanFqdn(application.fqdn));
const statusLabel =
status === "running" ||
status === "stopped" ||
status === "exited" ||
status === "starting" ||
status === "restarting"
? t(`status.${status}`)
: t("status.unknown");
return (
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="dot" color={getStatusColor(status)}>
{statusLabel}
</Badge>
{publicUrl ? (
<Anchor
className={actionTargetClasses.root}
href={publicUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
truncate="end"
style={{ textDecoration: "none" }}
>
{application.name}
</Anchor>
) : (
<Text fz="xs" truncate="end">
{application.name}
</Text>
)}
</Group>
);
};
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import classes from "./component.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
@@ -17,11 +20,12 @@ interface SingleInstanceLayoutProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) {
const t = useI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
defaultValue: false,
@@ -30,17 +34,15 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
const relativeTime = useTimeAgo(instance.updatedAt);
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
return (
<ScrollArea h="100%">
<Stack gap={0}>
@@ -51,18 +53,43 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
oolify
</Text>
</Group>
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "xs" : "sm"} fw={500} c="dimmed" lineClamp={1}>
{displayUrl}
</Anchor>
<Stack gap={0} miw={0}>
{isAdvanced && (
<Text fz="xs" fw={600} truncate="end">
{instance.integrationName}
</Text>
)}
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl}
</Anchor>
</Stack>
</Group>
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
<Accordion
className={classes.accordion}
variant="contained"
chevronPosition="right"
multiple
keepMounted={false}
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -71,26 +98,35 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
)}
</Accordion>
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
<InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />
</Stack>
</ScrollArea>
);
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{version}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
);
};