diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index e8000ffee..d3542cd17 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -1,166 +1,16 @@ import { RenderBaseCommand } from "../../lib/basecommands/RenderBaseCommand.js"; -import { FC, ReactNode } from "react"; -import { SingleResult } from "../../rendering/react/components/SingleResult.js"; -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 { RenderJson } from "../../rendering/react/json/RenderJson.js"; -import { useRenderContext } from "../../rendering/react/context.js"; -import { LocalFilename } from "../../rendering/react/components/LocalFilename.js"; -import Context, { - ContextKey, - ContextValue, - ContextValueSource, -} from "../../lib/context/Context.js"; - -const ContextSourceValue: FC<{ source: ContextValueSource }> = ({ source }) => { - switch (source.type) { - case "user": - return ( - - ); - case "terraform": - return ( - - ); - case "ddev": - return ( - - ); - case "dotfile": - return ( - - ); - default: - return ; - } -}; - -const ContextSourceKnownValue: FC<{ - name: string; - source: ContextValueSource; - relative?: boolean; -}> = ({ name, source, relative }) => { - return ( - - {name}, in{" "} - - - ); -}; - -const ContextSourceUnknown: FC = () => { - return unknown; -}; - -const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { - return ( - - (source: ) - - ); -}; - -const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { - const rows: Record = {}; - const { renderAsJson } = useRenderContext(); - const values: Record = {}; - - let hasTerraformSource = false; - let hasDDEVSource = false; - let hasDotfileSource = false; - - for (const key of [ - "project-id", - "server-id", - "org-id", - "installation-id", - "stack-id", - ] as ContextKey[]) { - const value = usePromise(ctx.getContextValue.bind(ctx), [key]); - if (value) { - rows[`--${key}`] = ( - - {value.value} - - ); - values[key] = value; - - hasTerraformSource = - hasTerraformSource || value.source.type === "terraform"; - hasDDEVSource = hasDDEVSource || value.source.type === "ddev"; - hasDotfileSource = hasDotfileSource || value.source.type === "dotfile"; - } else { - rows[`--${key}`] = ; - } - } - - if (renderAsJson) { - return ; - } - - return ( - - - - - {hasTerraformSource && } - {hasDDEVSource && } - {hasDotfileSource && } - - - ); -}; - -const TerraformHint: FC = () => ( - - You are in a directory that contains a terraform state file; some of the - context values were read from there. - -); - -const DDEVHint: FC = () => ( - - You are in a directory that contains a DDEV project; some of the context - values were read from there. - -); - -const DotfileHint: FC = () => ( - - You are in a directory that contains a .mw-context.json file; some of the - context values were read from there. - -); - -const ContextSetHint: FC = () => ( - - Use the mw context set command to set one of the values - listed above. - -); +import { ReactNode } from "react"; +import { Set as SetCommand } from "./set.js"; +import Context from "../../lib/context/Context.js"; +import { ContextOverview } from "../../rendering/react/components/Context/ContextOverview.js"; 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 { const ctx = new Context(this.apiClient, this.config); - return ; + return ; } } diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts new file mode 100644 index 000000000..7d39ca794 --- /dev/null +++ b/src/lib/context/projectOverview.ts @@ -0,0 +1,302 @@ +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; + warnings?: string[]; +}; + +async function fetchDatabaseLookup( + apiClient: MittwaldAPIV2Client, + projectId: string, + warnings: string[], +): Promise> { + 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 { + warnings.push("Could not fetch MySQL databases for project overview."); + } + + 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 { + warnings.push("Could not fetch Redis databases for project overview."); + } + + return databaseById; +} + +async function fetchStacksAndContainers( + apiClient: MittwaldAPIV2Client, + projectId: string, + warnings: string[], +): Promise<{ stacks: StackSummary[]; containers: ContainerSummary[] }> { + try { + const stackResponse = await apiClient.container.listStacks({ + projectId, + }); + assertStatus(stackResponse, 200); + + const stacks: StackSummary[] = 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, + })); + + const containers: ContainerSummary[] = stackResponse.data.flatMap((stack) => + (stack.services ?? []).map((service) => ({ + id: service.id, + shortId: service.shortId, + name: service.serviceName, + status: service.status, + stackId: service.stackId, + })), + ); + + return { stacks, containers }; + } catch { + warnings.push("Could not fetch container stacks for project overview."); + return { stacks: [], containers: [] }; + } +} + +async function fetchAppNames( + apiClient: MittwaldAPIV2Client, + appIds: string[], + warnings: string[], +): Promise> { + const appNames = new Map(); + let failedLookups = 0; + + await Promise.all( + appIds.map(async (appId) => { + try { + const app = await getAppFromUuid(apiClient, appId); + appNames.set(appId, app.name); + } catch { + failedLookups += 1; + appNames.set(appId, appId); + } + }), + ); + + if (failedLookups > 0) { + warnings.push( + `Could not resolve ${failedLookups} app name${failedLookups === 1 ? "" : "s"}; falling back to app IDs.`, + ); + } + + return appNames; +} + +export type OverviewEntryData = { + shortId?: string; + name: string; + status: string; + id: string; +}; + +export function formatOverviewEntry({ + shortId, + name, + status, + id, +}: OverviewEntryData): string { + if (shortId) { + return `${name} (${shortId}): ${status} (${id})`; + } else { + return `${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; + const warnings: string[] = []; + + if (!projectId) { + return { + apps: [], + stacks: [], + containers: [], + unavailableReason: unavailableReason ?? "project could not be resolved", + warnings, + }; + } + + try { + const [projectResponse, appInstallationsResponse] = await Promise.all([ + apiClient.project.getProject({ + projectId, + }), + apiClient.app.listAppinstallations({ + projectId, + }), + ]); + assertStatus(projectResponse, 200); + assertStatus(appInstallationsResponse, 200); + + const appInstallations = appInstallationsResponse.data; + const uniqueAppIds = Array.from( + new Set(appInstallations.map((installation) => installation.appId)), + ); + + const [appNames, databaseById, stackAndContainerData] = await Promise.all([ + fetchAppNames(apiClient, uniqueAppIds, warnings), + fetchDatabaseLookup(apiClient, projectId, warnings), + fetchStacksAndContainers(apiClient, projectId, warnings), + ]); + + 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, + }; + }); + + return { + projectId, + projectShortId: (projectResponse.data as { shortId?: string }).shortId, + projectName: projectResponse.data.description, + resolvedFrom, + apps, + stacks: stackAndContainerData.stacks, + containers: stackAndContainerData.containers, + warnings, + }; + } catch { + warnings.push( + "Could not fetch project-level context data with current access/context.", + ); + + return { + projectId, + resolvedFrom, + apps: [], + stacks: [], + containers: [], + unavailableReason: + "project-level data could not be fetched with current access/context", + warnings, + }; + } +} diff --git a/src/rendering/react/components/Context/ContextOverview.tsx b/src/rendering/react/components/Context/ContextOverview.tsx new file mode 100644 index 000000000..6d0b04f84 --- /dev/null +++ b/src/rendering/react/components/Context/ContextOverview.tsx @@ -0,0 +1,325 @@ +import { FC, ReactNode } from "react"; +import { usePromise } from "@mittwald/react-use-promise"; +import { Box, Text } from "ink"; +import { SingleResult } from "../SingleResult.js"; +import { Value } from "../Value.js"; +import { Note } from "../Note.js"; +import { LocalFilename } from "../LocalFilename.js"; +import { RenderJson } from "../../json/RenderJson.js"; +import { useRenderContext } from "../../context.js"; +import Context, { + ContextKey, + ContextValue, + ContextValueSource, +} from "../../../../lib/context/Context.js"; +import { + fetchProjectOverview, + formatOverviewEntry, + ProjectOverview, + resolveProjectContext, +} from "../../../../lib/context/projectOverview.js"; + +type ContextValues = Record; + +const ContextSourceValue: FC<{ source: ContextValueSource }> = ({ source }) => { + switch (source.type) { + case "user": + return ( + + ); + case "terraform": + return ( + + ); + case "ddev": + return ( + + ); + case "dotfile": + return ( + + ); + default: + return ; + } +}; + +const ContextSourceKnownValue: FC<{ + name: string; + source: ContextValueSource; + relative?: boolean; +}> = ({ name, source, relative }) => { + return ( + + {name}, in{" "} + + + ); +}; + +const ContextSourceUnknown: FC = () => { + return unknown; +}; + +const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { + return ( + + (source: ) + + ); +}; + +const ProjectOverviewSection: FC<{ + overview: ProjectOverview; + contextValues: ContextValues; +}> = ({ overview, contextValues }) => { + const stackDisplayById = new Map( + overview.stacks.map((stack) => [stack.id, stack.shortId ?? ""]), + ); + + if (overview.unavailableReason) { + return ( + + Project overview is unavailable: {overview.unavailableReason} + + ); + } + + const installationIdContext = contextValues["installation-id"]?.value; + const stackIdContext = contextValues["stack-id"]?.value; + const projectIdContext = contextValues["project-id"]?.value; + + 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) => { + const isDirectContext = app.installationId === installationIdContext; + const textColor = isDirectContext ? "green" : "gray"; + return ( + + + {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) => { + const isDirectContext = stack.id === stackIdContext; + const textColor = isDirectContext ? "green" : "gray"; + return ( + + {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}` + : ""; + const isDirectContext = container.stackId === stackIdContext; + const textColor = isDirectContext ? "green" : "gray"; + + return ( + + {formatOverviewEntry({ + shortId: container.shortId, + name: container.name, + status: `${container.status}${stackSuffix}`, + id: container.id, + })} + + ); + })} + + ) : ( + none found in this project + ); + + return ; +}; + +const TerraformHint: FC = () => ( + + You are in a directory that contains a terraform state file; some of the + context values were read from there. + +); + +const DDEVHint: FC = () => ( + + You are in a directory that contains a DDEV project; some of the context + values were read from there. + +); + +const DotfileHint: FC = () => ( + + You are in a directory that contains a .mw-context.json file; some of the + context values were read from there. + +); + +const ContextSetHint: FC = () => ( + + Use the mw context set command to set one of the values + listed above. + +); + +export const ContextOverview: FC<{ ctx: Context }> = ({ ctx }) => { + const rows: Record = {}; + const { renderAsJson, apiClient } = useRenderContext(); + const values: Record = {}; + + let hasTerraformSource = false; + let hasDDEVSource = false; + let hasDotfileSource = false; + + for (const key of [ + "project-id", + "server-id", + "org-id", + "installation-id", + "stack-id", + ] as ContextKey[]) { + const value = usePromise(ctx.getContextValue.bind(ctx), [key]); + if (value) { + rows[`--${key}`] = ( + + {value.value} + + ); + values[key] = value; + + hasTerraformSource = + hasTerraformSource || value.source.type === "terraform"; + hasDDEVSource = hasDDEVSource || value.source.type === "ddev"; + hasDotfileSource = hasDotfileSource || value.source.type === "dotfile"; + } else { + rows[`--${key}`] = ; + } + } + + 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 ( + + + + + + + + {hasTerraformSource && } + {hasDDEVSource && } + {hasDotfileSource && } + + + ); +};