diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index e8000ffee..62b5133e1 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -5,7 +5,7 @@ import { Value } from "../../rendering/react/components/Value.js"; import { usePromise } from "@mittwald/react-use-promise"; import { Note } from "../../rendering/react/components/Note.js"; import { Box, Text } from "ink"; -import { Set } from "./set.js"; +import { Set as SetCommand } from "./set.js"; import { RenderJson } from "../../rendering/react/json/RenderJson.js"; import { useRenderContext } from "../../rendering/react/context.js"; import { LocalFilename } from "../../rendering/react/components/LocalFilename.js"; @@ -14,6 +14,12 @@ import Context, { ContextValue, ContextValueSource, } from "../../lib/context/Context.js"; +import { + fetchProjectOverview, + formatOverviewEntry, + ProjectOverview, + resolveProjectContext, +} from "../../lib/context/projectOverview.js"; const ContextSourceValue: FC<{ source: ContextValueSource }> = ({ source }) => { switch (source.type) { @@ -75,9 +81,122 @@ const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { ); }; +const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ + overview, +}) => { + const stackDisplayById = new Map( + overview.stacks.map((stack) => [stack.id, stack.shortId ?? ""]), + ); + + if (overview.unavailableReason) { + return ( + + Project overview is unavailable: {overview.unavailableReason} + + ); + } + + const rows: Record = { + Project: ( + + {overview.projectName ?? overview.projectId}{" "} + + ({overview.projectShortId ?? overview.projectId}, resolved from{" "} + {overview.resolvedFrom ?? "project-id"}) + + + ), + }; + + rows["Apps"] = + overview.apps.length > 0 ? ( + + {overview.apps.map((app) => ( + + + {formatOverviewEntry({ + shortId: app.installationShortId, + name: app.appName, + status: `installed at ${app.installationPath}`, + id: app.installationId, + })} + + {app.linkedDatabases.length > 0 ? ( + app.linkedDatabases.map((db) => ( + + database {db.purpose}: {db.name ?? db.databaseId} ({db.kind}) + + )) + ) : ( + no linked databases + )} + + ))} + + ) : ( + none found in this project + ); + + rows["Stacks"] = + overview.stacks.length > 0 ? ( + + + {overview.stacks.length} total + + {overview.stacks.slice(0, 5).map((stack) => ( + + {formatOverviewEntry({ + shortId: stack.shortId, + name: stack.description ?? "stack", + status: `${stack.services} services, ${stack.volumes} volumes`, + id: stack.id, + })} + + ))} + + ) : ( + none found in this project + ); + + rows["Containers"] = + overview.containers.length > 0 ? ( + + + {overview.containers.length} total + + {overview.containers.slice(0, 8).map((container) => { + const stackShortId = container.stackId + ? (stackDisplayById.get(container.stackId) ?? "") + : ""; + const stackSuffix = container.stackId + ? ` | stack ${stackShortId}` + : ""; + + return ( + + {formatOverviewEntry({ + shortId: container.shortId, + name: container.name, + status: `${container.status}${stackSuffix}`, + id: container.id, + })} + + ); + })} + + ) : ( + none found in this project + ); + + return ; +}; + const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { const rows: Record = {}; - const { renderAsJson } = useRenderContext(); + const { renderAsJson, apiClient } = useRenderContext(); const values: Record = {}; let hasTerraformSource = false; @@ -109,8 +228,34 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { } } + const projectIdFromContext = values["project-id"]?.value; + const appInstallationId = values["installation-id"]?.value; + + const resolvedProject = usePromise( + ( + contextProjectId: string | undefined, + installationId: string | undefined, + ) => resolveProjectContext(apiClient, contextProjectId, installationId), + [projectIdFromContext, appInstallationId], + ); + + const overview = usePromise( + (resolvedProjectContext: { + projectId?: string; + resolvedFrom?: "project-id" | "installation-id"; + unavailableReason?: string; + }): Promise => + fetchProjectOverview(apiClient, resolvedProjectContext), + [resolvedProject], + ); + if (renderAsJson) { - return ; + return ( + <> + + + + ); } return ( @@ -118,6 +263,9 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { + + + {hasTerraformSource && } {hasDDEVSource && } {hasDotfileSource && } @@ -156,7 +304,7 @@ const ContextSetHint: FC = () => ( export class Get extends RenderBaseCommand { static summary = "Print an overview of currently set context parameters"; - static description = Set.description; + static description = SetCommand.description; static flags = { ...RenderBaseCommand.buildFlags() }; protected render(): ReactNode { diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts new file mode 100644 index 000000000..cdbfa4be8 --- /dev/null +++ b/src/lib/context/projectOverview.ts @@ -0,0 +1,251 @@ +import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { assertStatus } from "@mittwald/api-client-commons"; +import { + getAppFromUuid, + getAppInstallationFromUuid, +} from "../resources/app/uuid.js"; + +type AppLinkedDatabase = MittwaldAPIV2.Components.Schemas.AppLinkedDatabase; + +export type LinkedDatabaseSummary = { + databaseId: string; + purpose: string; + kind: "mysql" | "redis" | "unknown"; + name?: string; +}; + +export type AppSummary = { + installationId: string; + installationShortId?: string; + appId: string; + appName: string; + installationPath: string; + linkedDatabases: LinkedDatabaseSummary[]; +}; + +export type StackSummary = { + id: string; + shortId?: string; + description?: string; + services: number; + volumes: number; +}; + +export type ContainerSummary = { + id: string; + shortId?: string; + name: string; + status: string; + stackId?: string; +}; + +export type ResolvedProjectContext = { + projectId?: string; + resolvedFrom?: "project-id" | "installation-id"; + unavailableReason?: string; +}; + +export type ProjectOverview = { + projectId?: string; + projectShortId?: string; + projectName?: string; + resolvedFrom?: "project-id" | "installation-id"; + apps: AppSummary[]; + stacks: StackSummary[]; + containers: ContainerSummary[]; + unavailableReason?: string; +}; + +export type OverviewEntryData = { + shortId?: string; + name: string; + status: string; + id: string; +}; + +export function formatOverviewEntry({ + shortId, + name, + status, + id, +}: OverviewEntryData): string { + return `${shortId ?? ""} ( ${name} ): ${status} ( ${id} )`; +} + +export async function resolveProjectContext( + apiClient: MittwaldAPIV2Client, + contextProjectId: string | undefined, + installationId: string | undefined, +): Promise { + if (contextProjectId) { + return { projectId: contextProjectId, resolvedFrom: "project-id" }; + } + + if (!installationId) { + return { + unavailableReason: + "no project-id in context and no installation-id to derive it from", + }; + } + + try { + const installation = await getAppInstallationFromUuid( + apiClient, + installationId, + ); + return { + projectId: installation.projectId, + resolvedFrom: "installation-id", + }; + } catch { + return { + unavailableReason: "could not resolve project from installation-id", + }; + } +} + +export async function fetchProjectOverview( + apiClient: MittwaldAPIV2Client, + resolvedProject: ResolvedProjectContext, +): Promise { + const { projectId, resolvedFrom, unavailableReason } = resolvedProject; + + if (!projectId) { + return { + apps: [], + stacks: [], + containers: [], + unavailableReason: unavailableReason ?? "project could not be resolved", + }; + } + + try { + const projectResponse = await apiClient.project.getProject({ + projectId, + }); + assertStatus(projectResponse, 200); + + const appInstallationsResponse = await apiClient.app.listAppinstallations({ + projectId, + }); + assertStatus(appInstallationsResponse, 200); + + const appInstallations = appInstallationsResponse.data; + const uniqueAppIds = Array.from( + new Set(appInstallations.map((installation) => installation.appId)), + ); + + const appNames = new Map(); + await Promise.all( + uniqueAppIds.map(async (appId) => { + try { + const app = await getAppFromUuid(apiClient, appId); + appNames.set(appId, app.name); + } catch { + appNames.set(appId, appId); + } + }), + ); + + const databaseById = new Map< + string, + { name: string; kind: "mysql" | "redis" } + >(); + + try { + const mysqlResponse = await apiClient.database.listMysqlDatabases({ + projectId, + }); + assertStatus(mysqlResponse, 200); + for (const db of mysqlResponse.data) { + databaseById.set(db.id, { name: db.name, kind: "mysql" }); + } + } catch { + // best effort + } + + try { + const redisResponse = await apiClient.database.listRedisDatabases({ + projectId, + }); + assertStatus(redisResponse, 200); + for (const db of redisResponse.data) { + databaseById.set(db.id, { name: db.name, kind: "redis" }); + } + } catch { + // best effort + } + + const apps: AppSummary[] = appInstallations.map((installation) => { + const linkedDatabases: LinkedDatabaseSummary[] = + installation.linkedDatabases.map((linked: AppLinkedDatabase) => { + const resolved = databaseById.get(linked.databaseId); + return { + databaseId: linked.databaseId, + purpose: linked.purpose, + kind: resolved?.kind ?? "unknown", + name: resolved?.name, + }; + }); + + return { + installationId: installation.id, + installationShortId: installation.shortId, + appId: installation.appId, + appName: appNames.get(installation.appId) ?? installation.appId, + installationPath: installation.installationPath, + linkedDatabases, + }; + }); + + let stacks: StackSummary[] = []; + let containers: ContainerSummary[] = []; + + try { + const [stackResponse, serviceResponse] = await Promise.all([ + apiClient.container.listStacks({ projectId }), + apiClient.container.listServices({ projectId }), + ]); + assertStatus(stackResponse, 200); + assertStatus(serviceResponse, 200); + + stacks = stackResponse.data.map((stack) => ({ + id: stack.id, + shortId: (stack as { shortId?: string }).shortId, + description: stack.description, + services: stack.services?.length ?? 0, + volumes: stack.volumes?.length ?? 0, + })); + + containers = serviceResponse.data.map((service) => ({ + id: service.id, + shortId: (service as { shortId?: string }).shortId, + name: service.serviceName, + status: service.status, + stackId: service.stackId, + })); + } catch { + // best effort + } + + return { + projectId, + projectShortId: (projectResponse.data as { shortId?: string }).shortId, + projectName: projectResponse.data.description, + resolvedFrom, + apps, + stacks, + containers, + }; + } catch { + return { + projectId, + resolvedFrom, + apps: [], + stacks: [], + containers: [], + unavailableReason: + "project-level data could not be fetched with current access/context", + }; + } +}