From 7b3fbbb3cd0b06fc58dd8e3bb9f60b632de22926 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Thu, 23 Jul 2026 14:01:46 +0200 Subject: [PATCH 01/10] Enrich context getter to provide project details --- src/commands/context/get.tsx | 342 ++++++++++++++++++++++++++++++++++- 1 file changed, 338 insertions(+), 4 deletions(-) diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index e8000ffee..9160a89fb 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -5,15 +5,62 @@ 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"; +import { MittwaldAPIV2 } from "@mittwald/api-client"; +import { assertStatus } from "@mittwald/api-client-commons"; import Context, { ContextKey, ContextValue, ContextValueSource, } from "../../lib/context/Context.js"; +import { + getAppFromUuid, + getAppInstallationFromUuid, +} from "../../lib/resources/app/uuid.js"; + +type AppLinkedDatabase = MittwaldAPIV2.Components.Schemas.AppLinkedDatabase; + +type LinkedDatabaseSummary = { + databaseId: string; + purpose: string; + kind: "mysql" | "redis" | "unknown"; + name?: string; +}; + +type AppSummary = { + installationId: string; + appId: string; + appName: string; + installationPath: string; + linkedDatabases: LinkedDatabaseSummary[]; +}; + +type StackSummary = { + id: string; + description?: string; + services: number; + volumes: number; +}; + +type ContainerSummary = { + id: string; + name: string; + status: string; + stackId?: string; +}; + +type ProjectOverview = { + projectId?: string; + projectName?: string; + resolvedFrom?: "project-id" | "installation-id"; + apps: AppSummary[]; + stacks: StackSummary[]; + containers: ContainerSummary[]; + unavailableReason?: string; +}; const ContextSourceValue: FC<{ source: ContextValueSource }> = ({ source }) => { switch (source.type) { @@ -75,9 +122,90 @@ const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { ); }; +const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ + overview, +}) => { + if (overview.unavailableReason) { + return ( + + Project overview is unavailable: {overview.unavailableReason} + + ); + } + + const rows: Record = { + Project: ( + + {overview.projectName ?? overview.projectId}{" "} + ({overview.projectId}) + + ), + "Resolved from": {overview.resolvedFrom ?? "project-id"}, + }; + + if (overview.apps.length > 0) { + rows["Apps"] = ( + + {overview.apps.map((app) => ( + + + {app.appName}{" "} + + ({app.installationPath}, {app.installationId}) + + + {app.linkedDatabases.length > 0 ? ( + app.linkedDatabases.map((db) => ( + + database {db.purpose}: {db.name ?? db.databaseId} ({db.kind}) + + )) + ) : ( + no linked databases + )} + + ))} + + ); + } else { + rows["Apps"] = none found in this project; + rows["Stacks"] = ( + + + {overview.stacks.length} total + + {overview.stacks.slice(0, 5).map((stack) => ( + + {stack.id}: {stack.services} services, {stack.volumes} volumes + {stack.description ? ` (${stack.description})` : ""} + + ))} + + ); + rows["Containers"] = ( + + + {overview.containers.length} total + + {overview.containers.slice(0, 8).map((container) => ( + + {container.name}: {container.status} + {container.stackId ? ` (stack ${container.stackId})` : ""} + + ))} + + ); + } + + 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 +237,211 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { } } + const projectIdFromContext = values["project-id"]?.value; + const appInstallationId = values["installation-id"]?.value; + + const resolvedProject = usePromise( + async ( + contextProjectId: string | undefined, + installationId: string | undefined, + ): Promise<{ + projectId?: string; + resolvedFrom?: "project-id" | "installation-id"; + unavailableReason?: string; + }> => { + 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", + }; + } + }, + [projectIdFromContext, appInstallationId], + ); + + const overview = usePromise( + async ( + projectId: string | undefined, + resolvedFrom: "project-id" | "installation-id" | undefined, + unavailableReason: string | undefined, + ): Promise => { + 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, + appId: installation.appId, + appName: appNames.get(installation.appId) ?? installation.appId, + installationPath: installation.installationPath, + linkedDatabases, + }; + }); + + if (apps.length > 0) { + return { + projectId, + projectName: projectResponse.data.description, + resolvedFrom, + apps, + stacks: [], + containers: [], + }; + } + + const stackResponse = await apiClient.container.listStacks({ + projectId, + }); + assertStatus(stackResponse, 200); + + const serviceResponse = await apiClient.container.listServices({ + projectId, + }); + assertStatus(serviceResponse, 200); + + const stacks: StackSummary[] = stackResponse.data.map((stack) => ({ + id: stack.id, + description: stack.description, + services: stack.services?.length ?? 0, + volumes: stack.volumes?.length ?? 0, + })); + + const containers: ContainerSummary[] = serviceResponse.data.map( + (service) => ({ + id: service.id, + name: service.serviceName, + status: service.status, + stackId: service.stackId, + }), + ); + + return { + projectId, + 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", + }; + } + }, + [ + resolvedProject.projectId, + resolvedProject.resolvedFrom, + resolvedProject.unavailableReason, + ], + ); + if (renderAsJson) { - return ; + return ( + <> + + + + ); } return ( @@ -118,6 +449,9 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { + + + {hasTerraformSource && } {hasDDEVSource && } {hasDotfileSource && } @@ -156,7 +490,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 { From 3d12b19b38ece8a2cbff8e1349f8ac18e35327d0 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Mon, 27 Jul 2026 08:39:08 +0200 Subject: [PATCH 02/10] Separate prject details module, show short IDs, both apps and container --- src/commands/context/get.tsx | 289 +++++------------------------ src/lib/context/projectOverview.ts | 235 +++++++++++++++++++++++ 2 files changed, 280 insertions(+), 244 deletions(-) create mode 100644 src/lib/context/projectOverview.ts diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 9160a89fb..72026890d 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -9,58 +9,16 @@ 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"; -import { MittwaldAPIV2 } from "@mittwald/api-client"; -import { assertStatus } from "@mittwald/api-client-commons"; import Context, { ContextKey, ContextValue, ContextValueSource, } from "../../lib/context/Context.js"; import { - getAppFromUuid, - getAppInstallationFromUuid, -} from "../../lib/resources/app/uuid.js"; - -type AppLinkedDatabase = MittwaldAPIV2.Components.Schemas.AppLinkedDatabase; - -type LinkedDatabaseSummary = { - databaseId: string; - purpose: string; - kind: "mysql" | "redis" | "unknown"; - name?: string; -}; - -type AppSummary = { - installationId: string; - appId: string; - appName: string; - installationPath: string; - linkedDatabases: LinkedDatabaseSummary[]; -}; - -type StackSummary = { - id: string; - description?: string; - services: number; - volumes: number; -}; - -type ContainerSummary = { - id: string; - name: string; - status: string; - stackId?: string; -}; - -type ProjectOverview = { - projectId?: string; - projectName?: string; - resolvedFrom?: "project-id" | "installation-id"; - apps: AppSummary[]; - stacks: StackSummary[]; - containers: ContainerSummary[]; - unavailableReason?: string; -}; + fetchProjectOverview, + ProjectOverview, + resolveProjectContext, +} from "../../lib/context/projectOverview.js"; const ContextSourceValue: FC<{ source: ContextValueSource }> = ({ source }) => { switch (source.type) { @@ -125,6 +83,10 @@ const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ overview, }) => { + const stackDisplayById = new Map( + overview.stacks.map((stack) => [stack.id, stack.shortId ?? stack.id]), + ); + if (overview.unavailableReason) { return ( @@ -137,21 +99,24 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ Project: ( {overview.projectName ?? overview.projectId}{" "} - ({overview.projectId}) + + ({overview.projectShortId ?? overview.projectId}) + ), "Resolved from": {overview.resolvedFrom ?? "project-id"}, }; - if (overview.apps.length > 0) { - rows["Apps"] = ( + rows["Apps"] = + overview.apps.length > 0 ? ( {overview.apps.map((app) => ( {app.appName}{" "} - ({app.installationPath}, {app.installationId}) + ({app.installationPath},{" "} + {app.installationShortId ?? app.installationId}) {app.linkedDatabases.length > 0 ? ( @@ -169,36 +134,49 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ ))} + ) : ( + none found in this project ); - } else { - rows["Apps"] = none found in this project; - rows["Stacks"] = ( + + rows["Stacks"] = + overview.stacks.length > 0 ? ( {overview.stacks.length} total {overview.stacks.slice(0, 5).map((stack) => ( - {stack.id}: {stack.services} services, {stack.volumes} volumes + {stack.shortId ?? stack.id}: {stack.services} services,{" "} + {stack.volumes} volumes {stack.description ? ` (${stack.description})` : ""} ))} + ) : ( + none found in this project ); - rows["Containers"] = ( + + rows["Containers"] = + overview.containers.length > 0 ? ( {overview.containers.length} total {overview.containers.slice(0, 8).map((container) => ( - {container.name}: {container.status} - {container.stackId ? ` (stack ${container.stackId})` : ""} + {container.shortId + ? `${container.shortId} (${container.name})` + : container.name} + : {container.status} + {container.stackId + ? ` (stack ${stackDisplayById.get(container.stackId) ?? container.stackId})` + : ""} ))} + ) : ( + none found in this project ); - } return ; }; @@ -241,198 +219,21 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { const appInstallationId = values["installation-id"]?.value; const resolvedProject = usePromise( - async ( + ( contextProjectId: string | undefined, installationId: string | undefined, - ): Promise<{ - projectId?: string; - resolvedFrom?: "project-id" | "installation-id"; - unavailableReason?: string; - }> => { - 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", - }; - } - }, + ) => resolveProjectContext(apiClient, contextProjectId, installationId), [projectIdFromContext, appInstallationId], ); const overview = usePromise( - async ( - projectId: string | undefined, - resolvedFrom: "project-id" | "installation-id" | undefined, - unavailableReason: string | undefined, - ): Promise => { - 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, - appId: installation.appId, - appName: appNames.get(installation.appId) ?? installation.appId, - installationPath: installation.installationPath, - linkedDatabases, - }; - }); - - if (apps.length > 0) { - return { - projectId, - projectName: projectResponse.data.description, - resolvedFrom, - apps, - stacks: [], - containers: [], - }; - } - - const stackResponse = await apiClient.container.listStacks({ - projectId, - }); - assertStatus(stackResponse, 200); - - const serviceResponse = await apiClient.container.listServices({ - projectId, - }); - assertStatus(serviceResponse, 200); - - const stacks: StackSummary[] = stackResponse.data.map((stack) => ({ - id: stack.id, - description: stack.description, - services: stack.services?.length ?? 0, - volumes: stack.volumes?.length ?? 0, - })); - - const containers: ContainerSummary[] = serviceResponse.data.map( - (service) => ({ - id: service.id, - name: service.serviceName, - status: service.status, - stackId: service.stackId, - }), - ); - - return { - projectId, - 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", - }; - } - }, - [ - resolvedProject.projectId, - resolvedProject.resolvedFrom, - resolvedProject.unavailableReason, - ], + (resolvedProjectContext: { + projectId?: string; + resolvedFrom?: "project-id" | "installation-id"; + unavailableReason?: string; + }): Promise => + fetchProjectOverview(apiClient, resolvedProjectContext), + [resolvedProject], ); if (renderAsJson) { diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts new file mode 100644 index 000000000..6b7fb2361 --- /dev/null +++ b/src/lib/context/projectOverview.ts @@ -0,0 +1,235 @@ +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 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", + }; + } +} From 546c0246a4bd85a09ecb0f550835a9efa5f8cf03 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Mon, 27 Jul 2026 09:34:21 +0200 Subject: [PATCH 03/10] format and highlighting for project overview entries --- src/commands/context/get.tsx | 53 ++++++++++++++++++------------ src/lib/context/projectOverview.ts | 16 +++++++++ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 72026890d..79d5e6a17 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -16,6 +16,7 @@ import Context, { } from "../../lib/context/Context.js"; import { fetchProjectOverview, + formatOverviewEntry, ProjectOverview, resolveProjectContext, } from "../../lib/context/projectOverview.js"; @@ -84,7 +85,7 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ overview, }) => { const stackDisplayById = new Map( - overview.stacks.map((stack) => [stack.id, stack.shortId ?? stack.id]), + overview.stacks.map((stack) => [stack.id, stack.shortId ?? ""]), ); if (overview.unavailableReason) { @@ -112,12 +113,13 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ {overview.apps.map((app) => ( - - {app.appName}{" "} - - ({app.installationPath},{" "} - {app.installationShortId ?? app.installationId}) - + + {formatOverviewEntry({ + shortId: app.installationShortId, + name: app.appName, + status: `installed at ${app.installationPath}`, + id: app.installationId, + })} {app.linkedDatabases.length > 0 ? ( app.linkedDatabases.map((db) => ( @@ -146,9 +148,12 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ {overview.stacks.slice(0, 5).map((stack) => ( - {stack.shortId ?? stack.id}: {stack.services} services,{" "} - {stack.volumes} volumes - {stack.description ? ` (${stack.description})` : ""} + {formatOverviewEntry({ + shortId: stack.shortId, + name: stack.description ?? "stack", + status: `${stack.services} services, ${stack.volumes} volumes`, + id: stack.id, + })} ))} @@ -162,17 +167,23 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ {overview.containers.length} total - {overview.containers.slice(0, 8).map((container) => ( - - {container.shortId - ? `${container.shortId} (${container.name})` - : container.name} - : {container.status} - {container.stackId - ? ` (stack ${stackDisplayById.get(container.stackId) ?? container.stackId})` - : ""} - - ))} + {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 diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts index 6b7fb2361..cdbfa4be8 100644 --- a/src/lib/context/projectOverview.ts +++ b/src/lib/context/projectOverview.ts @@ -56,6 +56,22 @@ export type ProjectOverview = { 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, From 7c8f6facc5cf1f2f447bef3f04e1715282eba175 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Mon, 27 Jul 2026 09:35:18 +0200 Subject: [PATCH 04/10] Make linter happy --- src/commands/context/get.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 79d5e6a17..14e1bbc9b 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -169,9 +169,11 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ {overview.containers.slice(0, 8).map((container) => { const stackShortId = container.stackId - ? stackDisplayById.get(container.stackId) ?? "" + ? (stackDisplayById.get(container.stackId) ?? "") + : ""; + const stackSuffix = container.stackId + ? ` | stack ${stackShortId}` : ""; - const stackSuffix = container.stackId ? ` | stack ${stackShortId}` : ""; return ( From 82ee5ec7fdacac65d3fb5e6469e073ce6d2873a4 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Mon, 27 Jul 2026 09:41:22 +0200 Subject: [PATCH 05/10] Improve output, move resolved from into project line --- src/commands/context/get.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 14e1bbc9b..62b5133e1 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -101,11 +101,11 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ {overview.projectName ?? overview.projectId}{" "} - ({overview.projectShortId ?? overview.projectId}) + ({overview.projectShortId ?? overview.projectId}, resolved from{" "} + {overview.resolvedFrom ?? "project-id"}) ), - "Resolved from": {overview.resolvedFrom ?? "project-id"}, }; rows["Apps"] = From b39cdeaa2a56660a513ecb3bff78a21bc19e2332 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Tue, 11 Aug 2026 14:58:54 +0200 Subject: [PATCH 06/10] consolidate redundant API calls --- src/lib/context/projectOverview.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts index cdbfa4be8..1b3532063 100644 --- a/src/lib/context/projectOverview.ts +++ b/src/lib/context/projectOverview.ts @@ -202,12 +202,10 @@ export async function fetchProjectOverview( let containers: ContainerSummary[] = []; try { - const [stackResponse, serviceResponse] = await Promise.all([ - apiClient.container.listStacks({ projectId }), - apiClient.container.listServices({ projectId }), - ]); + const stackResponse = await apiClient.container.listStacks({ + projectId, + }); assertStatus(stackResponse, 200); - assertStatus(serviceResponse, 200); stacks = stackResponse.data.map((stack) => ({ id: stack.id, @@ -217,13 +215,15 @@ export async function fetchProjectOverview( 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, - })); + containers = stackResponse.data.flatMap((stack) => + (stack.services ?? []).map((service) => ({ + id: service.id, + shortId: service.shortId, + name: service.serviceName, + status: service.status, + stackId: service.stackId, + })), + ); } catch { // best effort } From 15799a925bbbe26a99a1fe045715324b92a0ad49 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Tue, 11 Aug 2026 15:42:58 +0200 Subject: [PATCH 07/10] improve coloring --- src/commands/context/get.tsx | 102 +++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 40 deletions(-) diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 62b5133e1..25e557251 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -81,9 +81,12 @@ const ContextSource: FC<{ source: ContextValueSource }> = ({ source }) => { ); }; -const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ - overview, -}) => { +type ContextValues = Record; + +const ProjectOverviewSection: FC<{ + overview: ProjectOverview; + contextValues: ContextValues; +}> = ({ overview, contextValues }) => { const stackDisplayById = new Map( overview.stacks.map((stack) => [stack.id, stack.shortId ?? ""]), ); @@ -96,10 +99,16 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ ); } + 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.projectName ?? overview.projectId} + {" "} ({overview.projectShortId ?? overview.projectId}, resolved from{" "} {overview.resolvedFrom ?? "project-id"}) @@ -111,30 +120,34 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ 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 - )} - - ))} + {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 @@ -146,16 +159,20 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ {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, - })} - - ))} + {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 @@ -174,9 +191,11 @@ const ProjectOverviewSection: FC<{ overview: ProjectOverview }> = ({ const stackSuffix = container.stackId ? ` | stack ${stackShortId}` : ""; + const isDirectContext = container.stackId === stackIdContext; + const textColor = isDirectContext ? "green" : "gray"; return ( - + {formatOverviewEntry({ shortId: container.shortId, name: container.name, @@ -264,7 +283,10 @@ const GetContext: FC<{ ctx: Context }> = ({ ctx }) => { - + {hasTerraformSource && } {hasDDEVSource && } From 8de001f69ca63a2c1b1e51c37a33c93b39554c66 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Tue, 11 Aug 2026 16:53:38 +0200 Subject: [PATCH 08/10] refactor and optimize --- src/commands/context/get.tsx | 328 +----------------- src/lib/context/projectOverview.ts | 202 ++++++----- .../components/Context/ContextOverview.tsx | 318 +++++++++++++++++ 3 files changed, 445 insertions(+), 403 deletions(-) create mode 100644 src/rendering/react/components/Context/ContextOverview.tsx diff --git a/src/commands/context/get.tsx b/src/commands/context/get.tsx index 25e557251..d3542cd17 100644 --- a/src/commands/context/get.tsx +++ b/src/commands/context/get.tsx @@ -1,328 +1,8 @@ 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 { ReactNode } from "react"; 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"; -import Context, { - ContextKey, - 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) { - 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: ) - - ); -}; - -type ContextValues = Record; - -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 GetContext: 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 && } - - - ); -}; - -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 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"; @@ -331,6 +11,6 @@ export class Get extends RenderBaseCommand { 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 index 1b3532063..79f0f9e3f 100644 --- a/src/lib/context/projectOverview.ts +++ b/src/lib/context/projectOverview.ts @@ -54,8 +54,108 @@ export type ProjectOverview = { stacks: StackSummary[]; containers: ContainerSummary[]; unavailableReason?: string; + warnings?: string[]; }; +async function fetchDatabaseLookup( + apiClient: MittwaldAPIV2Client, + projectId: string, + warnings: string[], +): Promise> { + const databaseById = new Map(); + + 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; @@ -109,6 +209,7 @@ export async function fetchProjectOverview( resolvedProject: ResolvedProjectContext, ): Promise { const { projectId, resolvedFrom, unavailableReason } = resolvedProject; + const warnings: string[] = []; if (!projectId) { return { @@ -116,18 +217,20 @@ export async function fetchProjectOverview( stacks: [], containers: [], unavailableReason: unavailableReason ?? "project could not be resolved", + warnings, }; } try { - const projectResponse = await apiClient.project.getProject({ - projectId, - }); + const [projectResponse, appInstallationsResponse] = await Promise.all([ + apiClient.project.getProject({ + projectId, + }), + apiClient.app.listAppinstallations({ + projectId, + }), + ]); assertStatus(projectResponse, 200); - - const appInstallationsResponse = await apiClient.app.listAppinstallations({ - projectId, - }); assertStatus(appInstallationsResponse, 200); const appInstallations = appInstallationsResponse.data; @@ -135,46 +238,11 @@ export async function fetchProjectOverview( 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 [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[] = @@ -198,46 +266,21 @@ export async function fetchProjectOverview( }; }); - let stacks: StackSummary[] = []; - let containers: ContainerSummary[] = []; - - try { - const stackResponse = await apiClient.container.listStacks({ - projectId, - }); - assertStatus(stackResponse, 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 = stackResponse.data.flatMap((stack) => - (stack.services ?? []).map((service) => ({ - id: service.id, - shortId: service.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, + stacks: stackAndContainerData.stacks, + containers: stackAndContainerData.containers, + warnings, }; } catch { + warnings.push( + "Could not fetch project-level context data with current access/context.", + ); + return { projectId, resolvedFrom, @@ -246,6 +289,7 @@ export async function fetchProjectOverview( 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..dd32f0a70 --- /dev/null +++ b/src/rendering/react/components/Context/ContextOverview.tsx @@ -0,0 +1,318 @@ +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 && } + + + ); +}; \ No newline at end of file From bddeba63888bb4d8cc5ce8fa5135d8ae1a912017 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Wed, 12 Aug 2026 08:17:06 +0200 Subject: [PATCH 09/10] Formatting --- src/lib/context/projectOverview.ts | 5 ++++- .../react/components/Context/ContextOverview.tsx | 15 +++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts index 79f0f9e3f..ce6bbbc57 100644 --- a/src/lib/context/projectOverview.ts +++ b/src/lib/context/projectOverview.ts @@ -62,7 +62,10 @@ async function fetchDatabaseLookup( projectId: string, warnings: string[], ): Promise> { - const databaseById = new Map(); + const databaseById = new Map< + string, + { name: string; kind: "mysql" | "redis" } + >(); try { const mysqlResponse = await apiClient.database.listMysqlDatabases({ diff --git a/src/rendering/react/components/Context/ContextOverview.tsx b/src/rendering/react/components/Context/ContextOverview.tsx index dd32f0a70..6d0b04f84 100644 --- a/src/rendering/react/components/Context/ContextOverview.tsx +++ b/src/rendering/react/components/Context/ContextOverview.tsx @@ -122,7 +122,11 @@ const ProjectOverviewSection: FC<{ const isDirectContext = app.installationId === installationIdContext; const textColor = isDirectContext ? "green" : "gray"; return ( - + {formatOverviewEntry({ shortId: app.installationShortId, @@ -137,7 +141,8 @@ const ProjectOverviewSection: FC<{ key={`${app.installationId}-${db.databaseId}-${db.purpose}`} color="gray" > - database {db.purpose}: {db.name ?? db.databaseId} ({db.kind}) + database {db.purpose}: {db.name ?? db.databaseId} ({db.kind} + ) )) ) : ( @@ -186,7 +191,9 @@ const ProjectOverviewSection: FC<{ const stackShortId = container.stackId ? (stackDisplayById.get(container.stackId) ?? "") : ""; - const stackSuffix = container.stackId ? ` | stack ${stackShortId}` : ""; + const stackSuffix = container.stackId + ? ` | stack ${stackShortId}` + : ""; const isDirectContext = container.stackId === stackIdContext; const textColor = isDirectContext ? "green" : "gray"; @@ -315,4 +322,4 @@ export const ContextOverview: FC<{ ctx: Context }> = ({ ctx }) => { ); -}; \ No newline at end of file +}; From bc5beffc85a4982e9ff3eb45f6a5b0cc742f8860 Mon Sep 17 00:00:00 2001 From: Lars Bergmann Date: Wed, 19 Aug 2026 10:32:27 +0200 Subject: [PATCH 10/10] Apply suggestions from code review Co-authored-by: Martin Helmich --- src/lib/context/projectOverview.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/context/projectOverview.ts b/src/lib/context/projectOverview.ts index ce6bbbc57..7d39ca794 100644 --- a/src/lib/context/projectOverview.ts +++ b/src/lib/context/projectOverview.ts @@ -172,7 +172,11 @@ export function formatOverviewEntry({ status, id, }: OverviewEntryData): string { - return `${shortId ?? ""} ( ${name} ): ${status} ( ${id} )`; + if (shortId) { + return `${name} (${shortId}): ${status} (${id})`; + } else { + return `${name}: ${status} (${id})`; + } } export async function resolveProjectContext(