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",
+ };
+ }
+}