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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user