IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

service.ts

Open raw
import "server-only";

import { LruCache } from "@/lib/server/cache/lru";
import { serverEnv } from "@/lib/server/env";
import { logServerAction, withServerTiming } from "@/lib/server/logging/logger";
import { resolveInstalledComposePath } from "@/lib/server/modules/apps/installed-compose-path";
import {
  findActiveStoreOperationsByAppIds,
  listInstalledAppsFromDb,
} from "@/lib/server/modules/apps/repository";
import {
  extractPrimaryServiceWithName,
  parseComposeFile,
} from "@/lib/server/modules/docker/compose-parser";
import { getComposeRuntimeInfo } from "@/lib/server/modules/docker/compose-runner";
import type { InstalledApp } from "@/lib/shared/contracts/apps";
import { readFile } from "node:fs/promises";
import path from "node:path";

const CDN = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png";

const DEMO_APPS: InstalledApp[] = [
  {
    id: "portainer",
    name: "Portainer",
    stackName: "portainer",
    composePath: "",
    webUiPort: 9000,
    containerName: "portainer",
    logoUrl: `${CDN}/portainer.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "jellyfin",
    name: "Jellyfin",
    stackName: "jellyfin",
    composePath: "",
    webUiPort: 8096,
    containerName: "jellyfin",
    logoUrl: `${CDN}/jellyfin.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "nextcloud",
    name: "Nextcloud",
    stackName: "nextcloud",
    composePath: "",
    webUiPort: 8080,
    containerName: "nextcloud",
    logoUrl: `${CDN}/nextcloud.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "vaultwarden",
    name: "Vaultwarden",
    stackName: "vaultwarden",
    composePath: "",
    webUiPort: 8081,
    containerName: "vaultwarden",
    logoUrl: `${CDN}/vaultwarden.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "pihole",
    name: "Pi-hole",
    stackName: "pihole",
    composePath: "",
    webUiPort: 8082,
    containerName: "pihole",
    logoUrl: `${CDN}/pi-hole.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "grafana",
    name: "Grafana",
    stackName: "grafana",
    composePath: "",
    webUiPort: 3000,
    containerName: "grafana",
    logoUrl: `${CDN}/grafana.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "uptime-kuma",
    name: "Uptime Kuma",
    stackName: "uptime-kuma",
    composePath: "",
    webUiPort: 3001,
    containerName: "uptime-kuma",
    logoUrl: `${CDN}/uptime-kuma.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
  {
    id: "immich",
    name: "Immich",
    stackName: "immich",
    composePath: "",
    webUiPort: 2283,
    containerName: "immich",
    logoUrl: `${CDN}/immich.png`,
    status: "running",
    updatedAt: new Date().toISOString(),
  },
];

const appsCache = new LruCache<InstalledApp[]>(4, 5_000);
const DB_UNAVAILABLE_BACKOFF_MS = 5_000;
let dbUnavailableUntil = 0;

export function invalidateInstalledAppsCache() {
  appsCache.delete("installed-apps");
  dbUnavailableUntil = 0;
}

function isDatabaseUnavailableError(error: unknown) {
  if (!(error instanceof Error)) return false;

  const nodeError = error as NodeJS.ErrnoException;
  const code = nodeError.code ?? "";

  if (code === "ECONNREFUSED") return true;
  return error.message.includes("ECONNREFUSED");
}

function parsePortFromUrl(value: string): number | null {
  try {
    const parsed = new URL(value);
    if (!parsed.port) return null;
    const port = Number.parseInt(parsed.port, 10);
    if (!Number.isInteger(port) || port < 1 || port > 65535) {
      return null;
    }
    return port;
  } catch {
    return null;
  }
}

function parseHostPortFromMapping(value: string): number | null {
  const trimmed = value.trim().replace(/^['"]|['"]$/g, "");
  if (!trimmed) return null;

  const [mappingPart] = trimmed.split("/");
  const segments = mappingPart.split(":").filter(Boolean);
  if (segments.length === 0) return null;

  const hostSegment =
    segments.length >= 2 ? segments[segments.length - 2] : segments[0];
  const port = Number.parseInt(hostSegment ?? "", 10);
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    return null;
  }
  return port;
}

async function inferComposePrimaryInfo(
  appId: string,
  composePath: string,
): Promise<{
  webUiPort: number | null;
  containerName: string | null;
}> {
  try {
    const composeContent = await readFile(composePath, "utf8");
    const parsed = parseComposeFile(composeContent);
    if (!parsed) {
      return {
        webUiPort: null,
        containerName: null,
      };
    }

    const primary = extractPrimaryServiceWithName(parsed, appId);
    if (!primary) {
      return {
        webUiPort: null,
        containerName: null,
      };
    }

    const appUrl = primary.service.environment?.APP_URL;
    if (typeof appUrl === "string" && appUrl.trim().length > 0) {
      const fromAppUrl = parsePortFromUrl(appUrl.trim());
      if (fromAppUrl !== null) {
        return {
          webUiPort: fromAppUrl,
          containerName: primary.service.containerName ?? null,
        };
      }
    }

    const firstPortMapping = primary.service.ports?.find(
      (entry) => typeof entry === "string" && entry.trim().length > 0,
    );
    return {
      webUiPort: firstPortMapping
        ? parseHostPortFromMapping(firstPortMapping)
        : null,
      containerName: primary.service.containerName ?? null,
    };
  } catch {
    return {
      webUiPort: null,
      containerName: null,
    };
  }
}

function mergeInstalledRuntimeState(input: {
  app: InstalledApp;
  composePath: string;
  runtimeStatus: InstalledApp["status"];
  condition: InstalledApp["condition"];
  inferredWebUiPort: number | null;
  containerName: string | null;
  activeOperation: InstalledApp["activeOperation"];
}) {
  return {
    ...input.app,
    composePath: input.composePath,
    webUiPort: input.inferredWebUiPort,
    status: input.runtimeStatus,
    condition: input.condition ?? null,
    containerName: input.containerName,
    activeOperation: input.activeOperation ?? null,
  } satisfies InstalledApp;
}

export async function listInstalledApps(options?: { bypassCache?: boolean }) {
  if (serverEnv.DEMO_MODE) return DEMO_APPS;

  return withServerTiming(
    {
      layer: "service",
      action: "apps.listInstalled",
      meta: {
        bypassCache: Boolean(options?.bypassCache),
      },
    },
    async () => {
      const now = Date.now();

      if (now < dbUnavailableUntil) {
        return [];
      }

      if (!options?.bypassCache) {
        const cached = appsCache.get("installed-apps");
        if (cached) return cached;
      }

      let apps: InstalledApp[];

      try {
        apps = await listInstalledAppsFromDb();
        dbUnavailableUntil = 0;
        const activeOperationsByAppId = await findActiveStoreOperationsByAppIds(
          apps.map((app) => app.id),
        );

        // Get real Docker status for each app
        const appsWithStatus = await Promise.all(
          apps.map(async (app) => {
            const resolvedComposePath = resolveInstalledComposePath({
              appId: app.id,
              composePath: app.composePath,
              stackName: app.stackName,
            });
            const composeInfo = await inferComposePrimaryInfo(
              app.id,
              resolvedComposePath,
            );
            const inferredWebUiPort = app.webUiPort ?? composeInfo.webUiPort;
            const envPath = path.join(
              path.dirname(resolvedComposePath),
              ".env",
            );
            try {
              const runtime = await getComposeRuntimeInfo({
                composePath: resolvedComposePath,
                envPath,
                stackName: app.stackName,
              });
              return mergeInstalledRuntimeState({
                app,
                composePath: resolvedComposePath,
                inferredWebUiPort,
                runtimeStatus: runtime.status,
                condition: runtime.condition,
                containerName:
                  runtime.primaryContainerName ?? composeInfo.containerName,
                activeOperation: activeOperationsByAppId[app.id] ?? null,
              });
            } catch {
              return mergeInstalledRuntimeState({
                app,
                composePath: resolvedComposePath,
                inferredWebUiPort,
                runtimeStatus: "unknown",
                condition: { condition: "unknown", exitCode: null },
                containerName: composeInfo.containerName,
                activeOperation: activeOperationsByAppId[app.id] ?? null,
              });
            }
          }),
        );

        apps = appsWithStatus;
      } catch (error) {
        if (!isDatabaseUnavailableError(error)) {
          throw error;
        }

        apps = [];
        dbUnavailableUntil = Date.now() + DB_UNAVAILABLE_BACKOFF_MS;
        logServerAction({
          level: "warn",
          layer: "service",
          action: "apps.listInstalled.fallback",
          status: "error",
          message: "Database unavailable; returning empty apps list",
          error,
          meta: {
            bypassCache: Boolean(options?.bypassCache),
          },
        });
      }

      appsCache.set("installed-apps", apps);
      return apps;
    },
  );
}
bytes
9,494
lines
341
Type
ts
Latest commit
16 October 2026