feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-08-13 19:55:11 +02:00
1 parent 2e3dca3e87
commit 84db2f9d49
68 files changed
+1425 -1311

No files matched your search

@@ -13,9 +13,10 @@ interface ApplicationsSectionProps {
applications: CoolifyApplicationWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
@@ -34,7 +35,14 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati
{applications.length > 0 ? (
<Stack gap={4}>
{applications.map((app) => (
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
<ResourceRow
key={app.uuid}
item={app}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="application"
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
) : (
+29 -98
View File
@@ -1,26 +1,17 @@
"use client";
import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils";
import { getUsableWidgetQueryData } from "../common/query-state";
import { createWidgetKey } 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,
height,
displayMode,
}: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -31,18 +22,7 @@ export default function CoolifyWidget({
);
}
const isAdvanced = displayMode === "advanced";
const sectionVisibility = getCoolifySectionVisibility(options, displayMode);
return (
<CoolifyContent
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
height={height}
isAdvanced={isAdvanced}
/>
);
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
}
interface CoolifyContentProps {
@@ -50,24 +30,19 @@ interface CoolifyContentProps {
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) {
const t = useScopedI18n("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 || height < 144;
const hideFooter = height < 112;
const [firstInstance] = successfulInstances;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (isPending) {
@@ -79,77 +54,33 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
</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) {
if (instancesData.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}
hideFooter={!isAdvanced && hideFooter}
/>
</div>
</Stack>
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
);
}
return (
<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}
hideFooter={!isAdvanced && hideFooter}
/>
))}
</SimpleGrid>
</ScrollArea>
</Stack>
<ScrollArea h="100%">
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
))}
</SimpleGrid>
</ScrollArea>
);
}
const FailedSourceBadges = ({
sources,
queryError,
}: {
sources: { integrationId: string; integrationName: string }[];
queryError: unknown;
}) => {
const t = useScopedI18n("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>
))}
<Box ml="auto">
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Box>
</Group>
);
};
+2
View File
@@ -6,6 +6,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: true,
icon: IconCloud,
mobile: { supportsDetailView: true },
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
+19 -42
View File
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
@@ -9,7 +8,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
import type { CoolifyOptions, InstanceData } from "./types";
@@ -19,12 +18,11 @@ interface InstanceCardProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
@@ -36,7 +34,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
@@ -44,10 +41,9 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
);
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
const runningApps = instance.instanceInfo.applications.filter(
(a) => parseStatus(a.status ?? "") === "running",
).length;
@@ -80,27 +76,20 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
>
<Group gap={4} wrap="nowrap" miw={0}>
<Group gap={4} wrap="nowrap">
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<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>
<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>
</Group>
<Group gap={4} wrap="nowrap">
{isTiny && totalResources > 0 ? (
@@ -136,20 +125,13 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Group>
</Group>
<Accordion
variant="filled"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -158,12 +140,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
@@ -1,28 +1,20 @@
"use client";
import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import {
IconCircleCheckFilled,
IconCircleFilled,
IconCircleXFilled,
IconEye,
IconEyeOff,
IconServer,
} from "@tabler/icons-react";
import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
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";
import { getBadgeColor } from "./coolify-utils";
interface ServersSectionProps {
servers: CoolifyServer[];
serverResourceCounts: Map<number, { apps: number; services: number }>;
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
showIp: boolean;
onToggleIp: () => void;
}
@@ -32,13 +24,12 @@ export function ServersSection({
serverResourceCounts,
baseUrl,
isTiny,
isAdvanced,
showIp,
onToggleIp,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const onlineServers = servers.filter(isCoolifyServerOnline).length;
const onlineServers = servers.filter((server) => server.is_reachable !== false).length;
return (
<Accordion.Item value="servers">
@@ -76,7 +67,6 @@ export function ServersSection({
}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
/>
))}
@@ -96,28 +86,23 @@ interface ServerRowProps {
counts: { apps: number; services: number };
baseUrl: string;
isTiny: boolean;
isAdvanced: boolean;
showIp: boolean;
}
function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) {
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const reachability = getCoolifyServerState(server, "is_reachable");
const usability = getCoolifyServerState(server, "is_usable");
const isOnline = server.is_reachable !== false;
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";
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
return (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<StatusIcon
aria-label={t(`status.${status}`)}
aria-label={t(isOnline ? "status.online" : "status.offline")}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${statusColor}-6)`}
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
@@ -144,30 +129,9 @@ function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: Serv
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<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 = useScopedI18n("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,24 +1,21 @@
"use client";
import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core";
import { Accordion, 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 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 { getBadgeColor, 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, isAdvanced }: ServicesSectionProps) {
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
@@ -37,19 +34,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
{services.length > 0 ? (
<Stack gap={4}>
{services.map((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>
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
))}
</Stack>
) : (
@@ -61,45 +46,3 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
</Accordion.Item>
);
}
type ServiceApplication = NonNullable<CoolifyServiceWithContext["applications"]>[number];
const ServiceApplicationRow = ({ application }: { application: ServiceApplication }) => {
const t = useScopedI18n("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,6 +1,5 @@
"use client";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -19,19 +18,11 @@ interface SingleInstanceLayoutProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function SingleInstanceLayout({
instance,
options,
isTiny,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -41,7 +32,6 @@ export function SingleInstanceLayout({
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
@@ -62,41 +52,27 @@ export function SingleInstanceLayout({
oolify
</Text>
</Group>
<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>
<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}
>
{displayUrl}
</Anchor>
</Group>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -105,12 +81,7 @@ export function SingleInstanceLayout({
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>