"use client"; import type { InstalledApp, StoreAppSummary, StoreOperationAction, StoreOperationStatus, } from "@/lib/shared/contracts/apps"; import type { UnmanagedContainer } from "@/lib/shared/contracts/docker"; import type { AppCondition, AppConditionSummary } from "@/lib/shared/app-condition"; import { AlertTriangle, BarChart3, BookOpen, Camera, Cloud, Code, Container, Database, Download, Film, FolderOpen, Gamepad2, Globe, Home, Lock, Mail, MessageSquare, Music, Pause, RefreshCw, Rss, Server, Shield, } from "@/components/icons/platform-icons"; import type { ComponentType } from "react"; import { isStoreOperationActiveStatus } from "@/lib/shared/store-operations"; export type AppGridStatus = | "running" | "partial" | "paused" | "stopped" | "unknown" | "updating" /** Running on this host, but not deployed by Homeio. */ | "unmanaged"; export type AppItem = { id: string; name: string; icon: ComponentType<{ className?: string }>; logoUrl: string | null; color: string; bgColor: string; status: AppGridStatus; category: string; webUiPort: number | null; webUiUrl: string | null; containerName: string | null; updateAvailable: boolean; /** Finer state from the server; the desktop falls back to status without it. */ condition?: AppConditionSummary | null; }; export type AppActionTarget = { appId: string; appName: string; dashboardUrl: string; containerName: string; /** A container Homeio did not deploy: its settings are name, icon and link only. */ unmanaged?: true; }; export const UNMANAGED_APP_ID_PREFIX = "container:"; export type AppOperationStateLike = { appId: string; action: StoreOperationAction; status: StoreOperationStatus; progressPercent: number; updatedAt?: string; }; export type ActiveAppOperation = { appId: string; appName: string; action: StoreOperationAction; status: "queued" | "running"; progressPercent: number; updatedAt: string; }; const iconByKeyword: Array<{ keywords: string[]; icon: ComponentType<{ className?: string }>; category: string; }> = [ { keywords: ["plex", "jellyfin"], icon: Film, category: "Media" }, { keywords: ["nextcloud", "cloud"], icon: Cloud, category: "Productivity" }, { keywords: ["pihole", "pi-hole"], icon: Shield, category: "Network" }, { keywords: ["home assistant", "home-asst"], icon: Home, category: "Automation", }, { keywords: ["portainer", "docker"], icon: Container, category: "System" }, { keywords: ["grafana"], icon: BarChart3, category: "System" }, { keywords: ["proxy", "nginx"], icon: Globe, category: "Network" }, { keywords: ["vaultwarden", "auth", "2fauth"], icon: Lock, category: "Security", }, { keywords: ["qbittorrent", "torrent"], icon: Download, category: "Downloads", }, { keywords: ["postgres", "mysql", "database"], icon: Database, category: "System", }, { keywords: ["music", "audio"], icon: Music, category: "Media" }, { keywords: ["gitea", "git"], icon: Code, category: "Development" }, { keywords: ["immich", "photo"], icon: Camera, category: "Media" }, { keywords: ["book", "wiki"], icon: BookOpen, category: "Productivity" }, { keywords: ["file"], icon: FolderOpen, category: "Productivity" }, { keywords: ["uptime", "kuma"], icon: Server, category: "System" }, { keywords: ["mail"], icon: Mail, category: "Communication" }, { keywords: ["matrix", "chat"], icon: MessageSquare, category: "Communication", }, { keywords: ["rss"], icon: Rss, category: "Productivity" }, { keywords: ["minecraft", "game"], icon: Gamepad2, category: "Gaming" }, ]; const visualPalette = [ { color: "text-sky-300", bgColor: "bg-sky-600/20" }, { color: "text-emerald-300", bgColor: "bg-emerald-600/20" }, { color: "text-amber-300", bgColor: "bg-amber-600/20" }, { color: "text-violet-300", bgColor: "bg-violet-600/20" }, { color: "text-rose-300", bgColor: "bg-rose-600/20" }, { color: "text-cyan-300", bgColor: "bg-cyan-600/20" }, { color: "text-lime-300", bgColor: "bg-lime-600/20" }, { color: "text-orange-300", bgColor: "bg-orange-600/20" }, ] as const; function hashText(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash << 5) - hash + value.charCodeAt(index); hash |= 0; } return Math.abs(hash); } export function pickVisual(appName: string, appId: string) { const name = appName.toLowerCase(); const matched = iconByKeyword.find((entry) => entry.keywords.some((keyword) => name.includes(keyword)), ) ?? null; const palette = visualPalette[hashText(appId) % visualPalette.length]; return { icon: matched?.icon ?? Container, category: matched?.category ?? "Apps", color: palette.color, bgColor: palette.bgColor, }; } export function resolveAppActionTarget(app: AppItem): AppActionTarget { const fallbackContainerName = app.containerName?.trim() ?? ""; const customUrl = app.webUiUrl?.trim() ?? ""; const kind = app.id.startsWith(UNMANAGED_APP_ID_PREFIX) ? { unmanaged: true as const } : {}; // An operator-set link wins: the automatic one is built from the browser's // own location, which is wrong behind a reverse proxy or tunnel. if (customUrl.length > 0) { return { appId: app.id, appName: app.name, dashboardUrl: customUrl, containerName: fallbackContainerName, ...kind, }; } if (app.webUiPort !== null) { const protocol = typeof window !== "undefined" ? window.location.protocol : "http:"; const hostname = typeof window !== "undefined" ? window.location.hostname : "localhost"; return { appId: app.id, appName: app.name, dashboardUrl: `${protocol}//${hostname}:${app.webUiPort}`, containerName: fallbackContainerName, ...kind, }; } return { appId: app.id, appName: app.name, dashboardUrl: "", containerName: fallbackContainerName, ...kind, }; } export function requireAppActionTarget( app: AppItem, options?: { requireContainerName?: boolean; requireDashboardUrl?: boolean; }, ): AppActionTarget | null { const fallback = resolveAppActionTarget(app); const requireContainerName = options?.requireContainerName ?? false; const requireDashboardUrl = options?.requireDashboardUrl ?? false; if (requireDashboardUrl && fallback.dashboardUrl.trim().length === 0) { return null; } if (requireContainerName && fallback.containerName.trim().length === 0) { return null; } return fallback; } type AppVisualState = { imageClass: string; ringClass: string; badgeIcon: ComponentType<{ className?: string }> | null; badgeClass: string; badgeIconClass: string; title: string; }; // Running apps keep their colours, anything down turns grey; a bare glyph over // the icon marks the states worth a look. No status dot. const COLOUR = ""; const GREY = "opacity-60 grayscale"; const PLAIN = { imageClass: COLOUR, ringClass: "border-transparent", badgeIcon: null, badgeClass: "", badgeIconClass: "" }; function withCode(label: string, exitCode: number | null | undefined, skipZero = true) { if (exitCode === null || exitCode === undefined || (skipZero && exitCode === 0)) return label; return `${label} (exit ${exitCode})`; } function conditionVisual(condition: AppCondition, exitCode: number | null): AppVisualState { switch (condition) { case "running": return { ...PLAIN, title: "Running" }; case "starting": return { ...PLAIN, title: "Starting" }; case "unhealthy": return { ...PLAIN, badgeIcon: AlertTriangle, badgeClass: "text-status-red", title: "Running but unhealthy" }; case "restarting": return { ...PLAIN, imageClass: GREY, badgeIcon: RefreshCw, badgeClass: "text-white", badgeIconClass: "animate-spin", title: withCode("Restarting", exitCode), }; case "partial": return { ...PLAIN, badgeIcon: AlertTriangle, badgeClass: "text-status-amber", title: exitCode === null ? "Degraded: a service is not running" : `Degraded: a service exited (code ${exitCode})`, }; case "paused": return { ...PLAIN, imageClass: GREY, badgeIcon: Pause, badgeClass: "text-white", title: "Paused" }; case "crashed": return { ...PLAIN, imageClass: GREY, badgeIcon: AlertTriangle, badgeClass: "text-status-red", title: withCode("Crashed", exitCode, false), }; case "stopped": return { ...PLAIN, imageClass: GREY, title: withCode("Stopped", exitCode) }; case "created": return { ...PLAIN, imageClass: GREY, ringClass: "border-dashed border-muted-foreground/30", title: "Never started" }; case "unknown": default: return { ...PLAIN, imageClass: GREY, title: "Status unknown" }; } } // The coarse status the grid already knows, from the server or from an // optimistic click (Stop shows as stopped before the server confirms). function coarseOf(condition: AppCondition): AppGridStatus { if (condition === "running" || condition === "starting" || condition === "unhealthy") return "running"; if (condition === "partial") return "partial"; if (condition === "paused") return "paused"; if (condition === "unknown") return "unknown"; return "stopped"; } function statusOnlyCondition(status: AppGridStatus): AppCondition { if (status === "running" || status === "partial" || status === "paused" || status === "stopped") return status; return "unknown"; } export function getAppVisualState(app: AppItem): AppVisualState { if (app.status === "updating") { return { ...PLAIN, imageClass: GREY, badgeIcon: RefreshCw, badgeClass: "text-white", badgeIconClass: "animate-spin", title: "Processing", }; } if (app.status === "unmanaged") { const condition = app.condition ?? { condition: "unknown" as const, exitCode: null }; const visual = conditionVisual(condition.condition, condition.exitCode); // Same colours as Homeio's apps; the dashed outline says someone else runs it. return { ...visual, ringClass: visual.ringClass.includes("border-transparent") ? "border-dashed border-muted-foreground/40" : `${visual.ringClass} border-dashed`, title: `${visual.title} ยท not managed by Homeio`, }; } // Trust the server's finer condition only while it agrees with the status // shown, so an optimistic Stop is not overridden by a stale "running". const condition = app.condition && coarseOf(app.condition.condition) === app.status ? app.condition : { condition: statusOnlyCondition(app.status), exitCode: null }; return conditionVisual(condition.condition, condition.exitCode); } export function buildUnmanagedAppItems( containers: UnmanagedContainer[], ): AppItem[] { return containers.map((container) => { const visual = pickVisual(container.name, container.id); return { id: `${UNMANAGED_APP_ID_PREFIX}${container.id}`, name: container.displayName ?? container.name, icon: visual.icon, logoUrl: container.iconUrl, color: visual.color, bgColor: visual.bgColor, status: "unmanaged" as const, condition: container.condition, category: "Containers", webUiPort: null, webUiUrl: container.dashboardUrl, containerName: container.name, updateAvailable: false, } satisfies AppItem; }); } export function buildAppItems(params: { installedApps: InstalledApp[]; installedCatalogApps: StoreAppSummary[]; operationsByApp: Record; statusByAppId: Record; }) { const { installedApps, installedCatalogApps, operationsByApp, statusByAppId } = params; const installedById = new Map(installedApps.map((app) => [app.id, app])); const catalogById = new Map(installedCatalogApps.map((app) => [app.id, app])); const ids = Array.from( new Set([...installedById.keys(), ...catalogById.keys()]), ); return ids .map((appId) => { const installed = installedById.get(appId); const catalog = catalogById.get(appId); const name = catalog?.name ?? installed?.name ?? appId; const visual = pickVisual(name, appId); let derivedStatus: AppGridStatus = "stopped"; if (catalog?.status === "installing" || catalog?.status === "updating") { derivedStatus = "updating"; } else if (installed?.status === "running") { derivedStatus = "running"; } else if (installed?.status === "partial") { derivedStatus = "partial"; } else if (installed?.status === "paused") { derivedStatus = "paused"; } else if (installed?.status === "stopped") { derivedStatus = "stopped"; } else if (installed?.status === "unknown") { derivedStatus = "unknown"; } const operationState = operationsByApp[appId]; if (operationState && isStoreOperationActiveStatus(operationState.status)) { derivedStatus = "updating"; } else if ( installed?.activeOperation && isStoreOperationActiveStatus(installed.activeOperation.status) ) { derivedStatus = "updating"; } const optimisticStatus = statusByAppId[appId]; if (optimisticStatus) { derivedStatus = optimisticStatus; } return { id: appId, name, icon: visual.icon, logoUrl: catalog?.logoUrl ?? installed?.logoUrl ?? null, color: visual.color, bgColor: visual.bgColor, status: derivedStatus, condition: installed?.condition ?? null, category: catalog?.categories[0] ?? visual.category, webUiPort: installed?.webUiPort ?? null, webUiUrl: installed?.webUiUrl ?? null, containerName: installed?.containerName ?? null, updateAvailable: catalog?.updateAvailable ?? false, } satisfies AppItem; }) .sort((left, right) => left.name.localeCompare(right.name)); } export function buildActiveAppOperations(params: { installedApps: InstalledApp[]; installedCatalogApps: StoreAppSummary[]; operationsByApp: Record; nowIso?: string; }) { const { installedApps, installedCatalogApps, operationsByApp, nowIso = new Date().toISOString(), } = params; const appNames = new Map(); for (const app of installedApps) { appNames.set(app.id, app.name); } for (const app of installedCatalogApps) { if (!appNames.has(app.id)) { appNames.set(app.id, app.name); } } const merged = new Map(); for (const app of installedApps) { if ( app.activeOperation && isStoreOperationActiveStatus(app.activeOperation.status) ) { merged.set(app.id, { appId: app.id, appName: appNames.get(app.id) ?? app.name, action: app.activeOperation.action, status: app.activeOperation.status, progressPercent: app.activeOperation.progressPercent, updatedAt: app.activeOperation.updatedAt, }); } } for (const operation of Object.values(operationsByApp)) { if (!isStoreOperationActiveStatus(operation.status)) { continue; } merged.set(operation.appId, { appId: operation.appId, appName: appNames.get(operation.appId) ?? operation.appId, action: operation.action, status: operation.status, progressPercent: operation.progressPercent, updatedAt: operation.updatedAt ?? nowIso, }); } return Array.from(merged.values()).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ); }