IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

service.ts

Buka raw
import "server-only";

import { logServerAction, withServerTiming } from "@/lib/server/logging/logger";
import { readFile } from "node:fs/promises";
import {
  findStoreCatalogTemplateByAppId,
  getStoreCatalogSnapshot,
  type StoreCatalogCategory,
} from "@/lib/server/modules/store/catalog";
import {
  findCustomStoreTemplateByAppId,
  listCustomStoreTemplates,
} from "@/lib/server/modules/store/custom-apps";
import {
  findInstalledStackByAppId,
  listInstalledStacksFromDb,
} from "@/lib/server/modules/apps/stacks-repository";
import { resolveInstalledComposePath } from "@/lib/server/modules/apps/installed-compose-path";
import {
  extractPrimaryServiceWithName,
  parseComposeFile,
} from "@/lib/server/modules/docker/compose-parser";
import { isTagUpdateAvailable } from "@/lib/server/modules/store/update-check";
import type {
  InstalledStackConfig,
  StoreAppDetail,
  StoreAppSummary,
  StoreAppSourceKind,
  StoreOperationAction,
} from "@/lib/shared/contracts/apps";
import { startStoreOperation } from "@/lib/server/modules/apps/operations";
import { patchInstalledStackMeta } from "@/lib/server/modules/apps/stacks-repository";

function includesSearch(text: string, search: string) {
  return text.toLowerCase().includes(search.toLowerCase());
}

const STORE_LIST_DESCRIPTION_LIMIT = 220;

function toSummaryDescription(value: string) {
  const normalized = value.replace(/\s+/g, " ").trim();
  if (normalized.length <= STORE_LIST_DESCRIPTION_LIMIT) {
    return normalized;
  }

  return `${normalized.slice(0, STORE_LIST_DESCRIPTION_LIMIT - 1).trimEnd()}…`;
}

function toSummary(
  template:
    | Awaited<ReturnType<typeof getStoreCatalogSnapshot>>["apps"][number]
    | Awaited<ReturnType<typeof listCustomStoreTemplates>>[number],
  installed:
    | (Awaited<ReturnType<typeof listInstalledStacksFromDb>>[number] & {
        installedImage?: string | null;
      })
    | undefined,
): StoreAppSummary {
  const sourceKind: StoreAppSourceKind = "isCustom" in template ? "custom" : template.sourceKind;
  const sourceId = "isCustom" in template ? template.appId : template.sourceId;
  const sourceName = "isCustom" in template ? "Custom" : template.sourceName;
  const installedImage = installed?.installedImage ?? null;

  return {
    id: template.appId,
    name: installed?.displayName ?? template.name,
    description: toSummaryDescription(template.description),
    platform: template.platform,
    categories: template.categories,
    logoUrl: installed?.iconUrl ?? template.logoUrl,
    repositoryUrl: template.repositoryUrl,
    stackFile: template.stackFile,
    status: installed?.status ?? "not_installed",
    webUiPort: installed?.webUiPort ?? template.port ?? null,
    updateAvailable: installed != null && installed.status !== "not_installed"
      ? isTagUpdateAvailable(installedImage, template.image)
      : false,
    installedImage,
    image: template.image ?? null,
    sourceId,
    sourceName,
    sourceKind,
  };
}


async function resolveInstalledImage(installed: InstalledStackConfig | null | undefined) {
  if (!installed || installed.status === "not_installed") {
    return null;
  }

  const composePath = resolveInstalledComposePath({
    appId: installed.appId,
    composePath: installed.composePath,
    stackName: installed.stackName,
  });

  try {
    const composeContent = await readFile(composePath, "utf8");
    const parsed = parseComposeFile(composeContent);
    if (!parsed) {
      return null;
    }

    return extractPrimaryServiceWithName(parsed, installed.appId)?.service.image ?? null;
  } catch {
    return null;
  }
}

export async function getStoreCatalogView(options?: {
  category?: string;
  search?: string;
  installedOnly?: boolean;
  updatesOnly?: boolean;
}) {
  return withServerTiming(
    {
      level: "debug",
      layer: "service",
      action: "store.apps.catalog-view",
      meta: {
        category: options?.category ?? null,
        search: options?.search ?? null,
        installedOnly: Boolean(options?.installedOnly),
        updatesOnly: Boolean(options?.updatesOnly),
      },
    },
    async () => {
      const [catalogSnapshot, customTemplates, installedStacks] = await Promise.all([
        getStoreCatalogSnapshot(),
        listCustomStoreTemplates(),
        listInstalledStacksFromDb(),
      ]);

      const allTemplates = [...catalogSnapshot.apps, ...customTemplates];
      const installedByAppId = new Map(installedStacks.map((item) => [item.appId, item]));
      const installedImageEntries = await Promise.all(
        installedStacks.map(async (stack) => [
          stack.appId,
          await resolveInstalledImage(stack),
        ] as const),
      );
      const installedImageByAppId = new Map(installedImageEntries);

      let templates = allTemplates;

      if (options?.category) {
        templates = templates.filter((template) =>
          template.categories.some(
            (category) => category.toLowerCase() === options.category?.toLowerCase(),
          ),
        );
      }

      if (options?.search) {
        templates = templates.filter(
          (template) =>
            includesSearch(template.name, options.search as string) ||
            includesSearch(template.description, options.search as string),
        );
      }

      let apps = templates
        .map((template) => {
          const installed = installedByAppId.get(template.appId);
          return toSummary(
            template,
            installed
              ? { ...installed, installedImage: installedImageByAppId.get(template.appId) ?? null }
              : undefined,
          );
        })
        .sort((left, right) => left.name.localeCompare(right.name));

      if (options?.installedOnly) {
        apps = apps.filter((app) => app.status !== "not_installed");
      }

      if (options?.updatesOnly) {
        apps = apps.filter((app) => app.updateAvailable);
      }

      const categories: StoreCatalogCategory[] = customTemplates.length > 0
        ? [
            ...catalogSnapshot.categories,
            {
              id: "custom",
              name: "Custom",
              description: "Custom compose apps",
              appCount: customTemplates.length,
            },
          ]
        : catalogSnapshot.categories;

      return {
        apps,
        categories,
        featuredAppIds: catalogSnapshot.featuredAppIds,
        recommendedAppIds: catalogSnapshot.recommendedAppIds,
        sourcePath: catalogSnapshot.sourcePath,
        sources: catalogSnapshot.sources,
      };
    },
  );
}

export async function listStoreApps(options?: {
  category?: string;
  search?: string;
  installedOnly?: boolean;
  updatesOnly?: boolean;
}) {
  const view = await getStoreCatalogView(options);
  return view.apps;
}

export async function getStoreAppDetail(appId: string): Promise<StoreAppDetail | null> {
  return withServerTiming(
    {
      level: "debug",
      layer: "service",
      action: "store.apps.detail",
      meta: {
        appId,
      },
    },
    async () => {
      const [template, customTemplate, installedConfig] = await Promise.all([
        findStoreCatalogTemplateByAppId(appId),
        findCustomStoreTemplateByAppId(appId),
        findInstalledStackByAppId(appId),
      ]);

      const sourceTemplate = template ?? customTemplate;
      if (!sourceTemplate) {
        return null;
      }

      const summary = toSummary(
        sourceTemplate,
        installedConfig
          ? {
              ...installedConfig,
              installedImage: await resolveInstalledImage(installedConfig),
            }
          : undefined,
      );

      return {
        ...summary,
        description: sourceTemplate.description,
        note: sourceTemplate.note,
        env: sourceTemplate.env,
        screenshots: sourceTemplate.screenshots,
        installedConfig,
      };
    },
  );
}

export async function startAppLifecycleAction(input: {
  appId: string;
  action: StoreOperationAction;
  displayName?: string;
  env?: Record<string, string>;
  webUiPort?: number;
  composeSource?: string;
  removeVolumes?: boolean;
  resetToCatalog?: boolean;
}) {
  return withServerTiming(
    {
      layer: "service",
      action: "store.apps.lifecycle",
      meta: {
        appId: input.appId,
        action: input.action,
      },
    },
    async () =>
      startStoreOperation({
        appId: input.appId,
        action: input.action,
        displayName: input.displayName,
        env: input.env,
        webUiPort: input.webUiPort,
        composeSource: input.composeSource,
        removeVolumes: input.removeVolumes,
        resetToCatalog: input.resetToCatalog,
      }),
  );
}

export async function saveAppSettings(input: {
  appId: string;
  displayName?: string;
  iconUrl?: string | null;
  env?: Record<string, string>;
  webUiPort?: number;
  webUiUrl?: string | null;
  composeSource?: string;
}): Promise<{ operationId?: string }> {
  return withServerTiming(
    {
      layer: "service",
      action: "store.apps.settings.save",
      meta: {
        appId: input.appId,
        hasDisplayName: input.displayName !== undefined,
        hasIconUrl: input.iconUrl !== undefined,
        hasEnv: input.env !== undefined,
        envKeyCount: Object.keys(input.env ?? {}).length,
        hasPort: Boolean(input.webUiPort !== undefined),
        hasWebUiUrl: input.webUiUrl !== undefined,
        hasComposeSource: input.composeSource !== undefined,
      },
      onSuccessMeta: (result) => ({
        triggeredRedeploy: Boolean((result as { operationId?: string }).operationId),
      }),
    },
    async () => {
      const { appId, displayName, iconUrl, env, webUiPort, webUiUrl, composeSource } =
        input;

      // webUiUrl only changes the link we render, so it never warrants a redeploy.
      if (displayName !== undefined || iconUrl !== undefined || webUiUrl !== undefined) {
        await patchInstalledStackMeta(appId, { displayName, iconUrl, webUiUrl });
      }

      if (env !== undefined || webUiPort !== undefined || composeSource !== undefined) {
        const result = await startStoreOperation({
          appId,
          action: "redeploy",
          env,
          webUiPort,
          composeSource,
        });
        return { operationId: result.operationId };
      }

      return {};
    },
  );
}

export async function checkAllAppsForUpdates() {
  return withServerTiming(
    {
      layer: "service",
      action: "store.apps.check-updates",
    },
    async () => {
      const [installed, catalogSnapshot, customTemplates] = await Promise.all([
        listInstalledStacksFromDb(),
        getStoreCatalogSnapshot(),
        listCustomStoreTemplates(),
      ]);

      const templateByAppId = new Map(
        [...catalogSnapshot.apps, ...customTemplates].map((t) => [t.appId, t]),
      );

      const results: Array<{ appId: string; updateAvailable: boolean }> = [];

      for (const app of installed) {
        if (app.status === "not_installed") {
          continue;
        }

        try {
          const template = templateByAppId.get(app.appId);
          const installedImage = await resolveInstalledImage(app);
          const updateAvailable = isTagUpdateAvailable(installedImage, template?.image);

          const { updateStackUpdateStatus } = await import("@/lib/server/modules/apps/stacks-repository");
          await updateStackUpdateStatus({
            appId: app.appId,
            isUpToDate: !updateAvailable,
          });

          results.push({ appId: app.appId, updateAvailable });
        } catch (error) {
          logServerAction({
            level: "warn",
            layer: "service",
            action: "store.apps.check-updates.app",
            status: "error",
            message: "Failed to check updates for app",
            meta: { appId: app.appId },
            error,
          });
        }
      }

      return results;
    },
  );
}
byte
11.968
baris
400
Jenis
ts
Commit terakhir
16 Oktober 2026