feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-07-28 19:39:12 +02:00
1 parent 9f0969ec22
commit 315520beca
116 files changed
+2499 -586

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>
) : (
+26 -4
View File
@@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
export default function CoolifyWidget({
options,
integrationIds,
width,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -21,16 +26,24 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} />;
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
isMobileDetail={displayMode === "mobileDetail"}
/>
);
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
isMobileDetail: boolean;
}
function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
function CoolifyContent({ integrationIds, options, width, isMobileDetail }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
@@ -38,7 +51,15 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
return <SingleInstanceLayout instance={firstInstance} options={options} isTiny={isTiny} widgetKey={widgetKey} />;
return (
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isMobileDetail={isMobileDetail}
/>
);
}
return (
@@ -51,6 +72,7 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps)
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
+2
View File
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
mobile: { supportsDetailView: true },
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
+15 -3
View File
@@ -18,9 +18,10 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isMobileDetail: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDetail }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -99,13 +100,24 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
<ApplicationsSection
applications={instance.instanceInfo.applications}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
</Accordion>
+38 -5
View File
@@ -3,6 +3,8 @@
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -21,11 +23,18 @@ interface ResourceRowProps {
baseUrl: string;
isTiny: boolean;
resourceType: "application" | "service";
isMobileDetail: boolean;
}
export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetail }: ResourceRowProps) {
const tCommon = useScopedI18n("common");
const tApp = useScopedI18n("widget.app");
const status = parseStatus(item.status ?? "");
const statusColor = getStatusColor(status);
const fqdn = cleanFqdn(item.fqdn);
const actionIconSize = isMobileDetail ? 44 : "xs";
const resourceLabel = resourceType === "application" ? tCommon("applications") : tCommon("services");
const openInNewTabLabel = tApp("option.openInNewTab.label");
const resourceUrl =
item.projectUuid && item.environmentUuid
@@ -49,18 +58,42 @@ 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">
{fqdn && (
<ActionIcon
component="a"
href={fqdn}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${openInNewTabLabel}: ${fqdn}`}
>
<IconLink size={12} />
</ActionIcon>
)}
{resourceUrl && (
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<ActionIcon
component="a"
href={resourceUrl}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${openInNewTabLabel}: ${resourceLabel} ${item.name}`}
>
<IconExternalLink size={12} />
</ActionIcon>
)}
{logsUrl && (
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<ActionIcon
component="a"
href={logsUrl}
target="_blank"
size={actionIconSize}
variant="subtle"
c="dimmed"
aria-label={`${tCommon("action.checkLogs")}: ${item.name}`}
>
<IconFileText size={12} />
</ActionIcon>
)}
@@ -15,6 +15,7 @@ interface ServersSectionProps {
isTiny: boolean;
showIp: boolean;
onToggleIp: () => void;
isMobileDetail: boolean;
}
export function ServersSection({
@@ -24,6 +25,7 @@ export function ServersSection({
isTiny,
showIp,
onToggleIp,
isMobileDetail,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
@@ -40,9 +42,11 @@ export function ServersSection({
</Badge>
</Group>
<ActionIcon
size="xs"
size={isMobileDetail ? 44 : "xs"}
variant="subtle"
c="dimmed"
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
aria-pressed={showIp}
onClick={(event) => {
event.stopPropagation();
onToggleIp();
@@ -65,6 +69,7 @@ export function ServersSection({
baseUrl={baseUrl}
isTiny={isTiny}
showIp={showIp}
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
@@ -84,10 +89,12 @@ interface ServerRowProps {
baseUrl: string;
isTiny: boolean;
showIp: boolean;
isMobileDetail: boolean;
}
function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
const tApp = useScopedI18n("widget.app");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
@@ -110,7 +117,15 @@ 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">
<ActionIcon
component="a"
href={serverUrl}
target="_blank"
size={isMobileDetail ? 44 : "xs"}
variant="subtle"
c="dimmed"
aria-label={`${tApp("option.openInNewTab.label")}: ${server.name}`}
>
<IconExternalLink size={12} />
</ActionIcon>
<Text fz="10px" c="dimmed">
@@ -13,9 +13,10 @@ interface ServicesSectionProps {
services: CoolifyServiceWithContext[];
baseUrl: string;
isTiny: boolean;
isMobileDetail: boolean;
}
export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
@@ -34,7 +35,14 @@ 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" />
<ResourceRow
key={service.uuid}
item={service}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="service"
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
) : (
@@ -18,9 +18,16 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isMobileDetail: boolean;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({
instance,
options,
isTiny,
widgetKey,
isMobileDetail,
}: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -65,13 +72,24 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
<ApplicationsSection
applications={instance.instanceInfo.applications}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isMobileDetail={isMobileDetail}
/>
)}
</Accordion>