feat: refine widget interactions
This commit is contained in:
1 parent
a8a7545f40
commit
842e86012d
107 files changed
+3144
-1233
No files matched your search
@@ -14,6 +14,7 @@ export default function CoolifyWidget({
|
||||
options,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"coolify">) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
@@ -31,6 +32,7 @@ export default function CoolifyWidget({
|
||||
integrationIds={integrationIds}
|
||||
options={options}
|
||||
width={width}
|
||||
height={height}
|
||||
isAdvanced={displayMode === "advanced"}
|
||||
/>
|
||||
);
|
||||
@@ -40,16 +42,31 @@ interface CoolifyContentProps {
|
||||
integrationIds: string[];
|
||||
options: WidgetComponentProps<"coolify">["options"];
|
||||
width: number;
|
||||
height: number;
|
||||
isAdvanced: boolean;
|
||||
}
|
||||
|
||||
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
|
||||
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
|
||||
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
|
||||
const t = useScopedI18n("common");
|
||||
const { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
const isTiny = !isAdvanced && width < 256;
|
||||
const isTiny = !isAdvanced && (width < 256 || height < 144);
|
||||
const hideFooter = !isAdvanced && height < 112;
|
||||
const [firstInstance] = instancesData;
|
||||
const widgetKey = createWidgetKey(integrationIds);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("action.loading")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (instancesData.length === 1 && firstInstance) {
|
||||
return (
|
||||
<SingleInstanceLayout
|
||||
@@ -58,6 +75,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +91,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC
|
||||
isTiny={isTiny}
|
||||
widgetKey={widgetKey}
|
||||
isAdvanced={isAdvanced}
|
||||
hideFooter={hideFooter}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ApplicationsSection } from "./applications-section";
|
||||
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
|
||||
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";
|
||||
@@ -18,12 +19,14 @@ interface InstanceCardProps {
|
||||
instance: InstanceData;
|
||||
options: CoolifyOptions;
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
widgetKey: string;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
|
||||
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const cardKey = `${widgetKey}-${instance.integrationId}`;
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${cardKey}`,
|
||||
@@ -33,44 +36,41 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
key: `coolify-sections-${cardKey}`,
|
||||
defaultValue: ["applications"],
|
||||
});
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
[
|
||||
options.showServers ? "servers" : null,
|
||||
options.showApplications ? "applications" : null,
|
||||
options.showServices ? "services" : null,
|
||||
].filter((section): section is string => section !== null),
|
||||
[options.showApplications, options.showServers, options.showServices],
|
||||
);
|
||||
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
|
||||
const previousVisibleSectionsRef = useRef(visibleSections);
|
||||
|
||||
useEffect(() => {
|
||||
const newlyVisibleSections = visibleSections.filter(
|
||||
(section) => !previousVisibleSectionsRef.current.includes(section),
|
||||
);
|
||||
previousVisibleSectionsRef.current = visibleSections;
|
||||
if (newlyVisibleSections.length === 0) return;
|
||||
|
||||
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
|
||||
}, [visibleSections]);
|
||||
|
||||
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 baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
|
||||
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
|
||||
const relativeTime = useTimeAgo(instance.updatedAt);
|
||||
|
||||
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
|
||||
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
|
||||
const runningApps = instance.instanceInfo.applications.filter(
|
||||
(a) => parseStatus(a.status ?? "") === "running",
|
||||
).length;
|
||||
const runningServices = instance.instanceInfo.services.filter(
|
||||
(s) => parseStatus(s.status ?? "") === "running",
|
||||
).length;
|
||||
const healthyResources =
|
||||
(options.showServers ? onlineServers : 0) +
|
||||
(options.showApplications ? runningApps : 0) +
|
||||
(options.showServices ? runningServices : 0);
|
||||
const totalResources =
|
||||
(options.showServers ? instance.instanceInfo.servers.length : 0) +
|
||||
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
|
||||
(options.showServices ? instance.instanceInfo.services.length : 0);
|
||||
const resourceSummary = [
|
||||
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
|
||||
options.showApplications
|
||||
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
|
||||
: null,
|
||||
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<Card p={0} radius="sm">
|
||||
@@ -80,19 +80,42 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
wrap="nowrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<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 && (
|
||||
{isTiny && totalResources > 0 ? (
|
||||
<Tooltip label={resourceSummary}>
|
||||
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
|
||||
{healthyResources}/{totalResources}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isTiny && options.showServers && (
|
||||
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
|
||||
{onlineServers}/{instance.instanceInfo.servers.length}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showApplications && (
|
||||
{!isTiny && options.showApplications && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
|
||||
@@ -101,7 +124,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
{runningApps}/{instance.instanceInfo.applications.length}
|
||||
</Badge>
|
||||
)}
|
||||
{options.showServices && (
|
||||
{!isTiny && options.showServices && (
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
|
||||
@@ -126,6 +149,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
serverResourceCounts={serverResourceCounts}
|
||||
baseUrl={baseUrl}
|
||||
isTiny={isTiny}
|
||||
isAdvanced={isAdvanced}
|
||||
showIp={showIp}
|
||||
onToggleIp={() => setShowIp((prev) => !prev)}
|
||||
/>
|
||||
@@ -134,18 +158,29 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
|
||||
<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>
|
||||
{!hideFooter && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
p={4}
|
||||
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), 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>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
|
||||
import { IconFileText, IconLink } from "@tabler/icons-react";
|
||||
|
||||
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
|
||||
|
||||
@@ -39,7 +39,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
|
||||
<Indicator size={isTiny ? 4 : 8} color={statusColor} />
|
||||
{resourceUrl ? (
|
||||
<Anchor href={resourceUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
|
||||
<Anchor
|
||||
href={resourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "8px" : "xs"}
|
||||
c="inherit"
|
||||
lineClamp={1}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
) : (
|
||||
@@ -50,24 +57,39 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
|
||||
</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">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={cleanFqdn(item.fqdn)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${item.name}`}
|
||||
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} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
{logsUrl && (
|
||||
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
|
||||
{!isTiny && logsUrl && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={logsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${item.name} logs`}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
>
|
||||
<IconFileText size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
{getResourceTimestamp(item, resourceType) && (
|
||||
{!isTiny && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1}>
|
||||
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
|
||||
</Text>
|
||||
)}
|
||||
{!isTiny && getResourceTimestamp(item, resourceType) && (
|
||||
<Text fz="10px" c="dimmed" ml="auto">
|
||||
{getResourceTimestamp(item, resourceType)}
|
||||
</Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
|
||||
import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
import { IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
|
||||
|
||||
import type { CoolifyServer } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -40,6 +40,7 @@ export function ServersSection({
|
||||
</Badge>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
@@ -96,7 +97,14 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
<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}>
|
||||
<Anchor
|
||||
href={serverUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "8px" : "xs"}
|
||||
c="inherit"
|
||||
lineClamp={1}
|
||||
>
|
||||
{server.name}
|
||||
</Anchor>
|
||||
{isBuildServer ? (
|
||||
@@ -110,9 +118,6 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
|
||||
)}
|
||||
</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 : "***.***.***.***"}
|
||||
</Text>
|
||||
|
||||
@@ -20,9 +20,17 @@ interface SingleInstanceLayoutProps {
|
||||
isTiny: boolean;
|
||||
widgetKey: string;
|
||||
isAdvanced: boolean;
|
||||
hideFooter: boolean;
|
||||
}
|
||||
|
||||
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isAdvanced }: SingleInstanceLayoutProps) {
|
||||
export function SingleInstanceLayout({
|
||||
instance,
|
||||
options,
|
||||
isTiny,
|
||||
widgetKey,
|
||||
isAdvanced,
|
||||
hideFooter,
|
||||
}: SingleInstanceLayoutProps) {
|
||||
const t = useScopedI18n("widget.coolify");
|
||||
const [showIp, setShowIp] = useLocalStorage({
|
||||
key: `coolify-show-ip-${widgetKey}`,
|
||||
@@ -74,7 +82,15 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
|
||||
oolify
|
||||
</Text>
|
||||
</Group>
|
||||
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "xs" : "sm"} fw={500} c="dimmed" lineClamp={1}>
|
||||
<Anchor
|
||||
href={baseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fz={isTiny ? "xs" : "sm"}
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
lineClamp={1}
|
||||
>
|
||||
{displayUrl}
|
||||
</Anchor>
|
||||
</Group>
|
||||
@@ -104,21 +120,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
|
||||
)}
|
||||
</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} />
|
||||
{!hideFooter && (
|
||||
<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">
|
||||
v{instance.instanceInfo.version}
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("footer.updated", { when: relativeTime })}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user