AI scaffolded NodeJS version of the App.

This commit is contained in:
2026-04-19 18:54:31 +02:00
parent 4ff0a7205f
commit aca4998da7
27 changed files with 8733 additions and 0 deletions

4
.gitignore vendored
View File

@@ -17,3 +17,7 @@
# MacOS Finder files
**/.DS_Store
# Node/React rewrite outputs
**/node_modules
/dist

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:24-trixie-slim AS build
WORKDIR /workspace
COPY app-new/backend/package*.json app-new/backend/
COPY app-new/frontend/package*.json app-new/frontend/
RUN cd app-new/backend && npm install
RUN cd app-new/frontend && npm install
COPY app-new app-new
COPY app/templates app/templates
COPY app/templates.json app/templates.json
RUN cd app-new/backend && npm run build
RUN cd app-new/frontend && npm run build
FROM node:24-trixie-slim AS runtime
WORKDIR /workspace
ENV NODE_ENV=production
ENV PORT=3000
COPY app-new/backend/package*.json app-new/backend/
RUN cd app-new/backend && npm install --omit=dev
COPY --from=build /workspace/dist dist
COPY --from=build /workspace/app app
COPY entrypoint.sh entrypoint.sh
RUN chmod +x entrypoint.sh
EXPOSE 3000
CMD ["./entrypoint.sh"]

3244
app-new/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "azure-image-chooser-backend",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/server.ts",
"start": "node ../../dist/backend/server.js",
"test": "vitest run"
},
"dependencies": {
"@azure/arm-compute": "^23.3.0",
"@azure/identity": "^4.13.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"nunjucks": "^3.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/nunjucks": "^3.2.6",
"@types/node": "^24.9.1",
"typescript": "^6.0.3",
"tsx": "^4.20.6",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,117 @@
import { ComputeManagementClient } from "@azure/arm-compute";
import { DefaultAzureCredential } from "@azure/identity";
import { MemoryCache } from "./cache";
import { sortImageVersionsIfSemantic } from "./version";
import type { LocationOption } from "./types";
const CACHE_TTL_MS = 5 * 60 * 1000;
export class AzureImageService {
private readonly credential = new DefaultAzureCredential();
private readonly computeClient: ComputeManagementClient;
private readonly cache = new MemoryCache();
public constructor(private readonly subscriptionId: string) {
this.computeClient = new ComputeManagementClient(this.credential, subscriptionId);
}
public async getLocations(): Promise<LocationOption[]> {
const cacheKey = "locations";
const cached = this.cache.get<LocationOption[]>(cacheKey);
if (cached) {
return cached;
}
const token = await this.credential.getToken("https://management.azure.com/.default");
const url = `https://management.azure.com/subscriptions/${this.subscriptionId}/locations?api-version=2022-12-01`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token?.token ?? ""}`
}
});
const payload = (await response.json()) as { value?: Array<{ name?: string; displayName?: string; metadata?: { regionType?: string } }> };
const locations = (payload.value ?? [])
.filter((loc) => loc.metadata?.regionType === "Physical" && Boolean(loc.name))
.map((loc) => ({
name: loc.name as string,
displayName: loc.displayName ?? (loc.name as string)
}));
locations.sort((a, b) => a.name.localeCompare(b.name));
this.cache.set(cacheKey, locations, CACHE_TTL_MS);
return locations;
}
public async getPublishers(location: string): Promise<string[]> {
const cacheKey = `publishers:${location}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listPublishers(location);
const publishers = this.extractNames(response);
this.cache.set(cacheKey, publishers, CACHE_TTL_MS);
return publishers;
}
public async getOffers(location: string, publisher: string): Promise<string[]> {
const cacheKey = `offers:${location}:${publisher}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listOffers(location, publisher);
const offers = this.extractNames(response);
this.cache.set(cacheKey, offers, CACHE_TTL_MS);
return offers;
}
public async getSkus(location: string, publisher: string, offer: string): Promise<string[]> {
const cacheKey = `skus:${location}:${publisher}:${offer}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listSkus(location, publisher, offer);
const skus = this.extractNames(response);
this.cache.set(cacheKey, skus, CACHE_TTL_MS);
return skus;
}
public async getVersions(location: string, publisher: string, offer: string, sku: string): Promise<string[]> {
const cacheKey = `versions:${location}:${publisher}:${offer}:${sku}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.list(location, publisher, offer, sku);
const versions = this.extractNames(response);
const sorted = sortImageVersionsIfSemantic(versions);
this.cache.set(cacheKey, sorted, CACHE_TTL_MS);
return sorted;
}
private extractNames(source: unknown): string[] {
const items = Array.isArray(source)
? source
: typeof source === "object" && source !== null && "value" in source && Array.isArray((source as { value?: unknown }).value)
? ((source as { value: unknown[] }).value as unknown[])
: [];
return items
.map((item) => (typeof item === "object" && item !== null && "name" in item ? (item as { name?: string }).name : undefined))
.filter((value): value is string => Boolean(value));
}
}

View File

@@ -0,0 +1,29 @@
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
export class MemoryCache {
private readonly store = new Map<string, CacheEntry<unknown>>();
public get<T>(key: string): T | undefined {
const hit = this.store.get(key);
if (!hit) {
return undefined;
}
if (Date.now() >= hit.expiresAt) {
this.store.delete(key);
return undefined;
}
return hit.value as T;
}
public set<T>(key: string, value: T, ttlMs: number): void {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
}

View File

@@ -0,0 +1,149 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import cors from "cors";
import "dotenv/config";
import express from "express";
import { z } from "zod";
import { AzureImageService } from "./azure-service";
import { TemplateService } from "./template-service";
const queryLocation = z.object({ location: z.string().min(1) });
const queryOffer = z.object({ location: z.string().min(1), publisher: z.string().min(1) });
const querySku = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1) });
const queryVersion = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1), sku: z.string().min(1) });
const renderBody = z.object({
templateFile: z.string().min(1),
selection: z.object({
location: z.string().min(1),
publisher: z.string().min(1),
offer: z.string().min(1),
sku: z.string().min(1),
version: z.string().min(1)
})
});
const makeApp = () => {
const app = express();
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
app.use(cors());
app.use(express.json());
if (!subscriptionId) {
app.get("/api/health", (_req, res) => {
res.status(500).json({
status: "error",
message: "Missing AZURE_SUBSCRIPTION_ID"
});
});
return { app, port };
}
const azure = new AzureImageService(subscriptionId);
const templates = new TemplateService();
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/api/locations", async (_req, res, next) => {
try {
res.json(await azure.getLocations());
} catch (error) {
next(error);
}
});
app.get("/api/publishers", async (req, res, next) => {
try {
const { location } = queryLocation.parse(req.query);
res.json(await azure.getPublishers(location));
} catch (error) {
next(error);
}
});
app.get("/api/offers", async (req, res, next) => {
try {
const { location, publisher } = queryOffer.parse(req.query);
res.json(await azure.getOffers(location, publisher));
} catch (error) {
next(error);
}
});
app.get("/api/skus", async (req, res, next) => {
try {
const { location, publisher, offer } = querySku.parse(req.query);
res.json(await azure.getSkus(location, publisher, offer));
} catch (error) {
next(error);
}
});
app.get("/api/versions", async (req, res, next) => {
try {
const { location, publisher, offer, sku } = queryVersion.parse(req.query);
res.json(await azure.getVersions(location, publisher, offer, sku));
} catch (error) {
next(error);
}
});
app.get("/api/templates", (_req, res) => {
res.json(templates.getTemplates());
});
app.post("/api/render", (req, res, next) => {
try {
const payload = renderBody.parse(req.body);
const rendered = templates.render(payload.templateFile, payload.selection);
res.json({ rendered });
} catch (error) {
next(error);
}
});
app.get("/api/sku-export", async (req, res, next) => {
try {
const { location, publisher, offer } = querySku.parse(req.query);
const skus = await azure.getSkus(location, publisher, offer);
res.json({ rendered: templates.buildSkuExport(skus) });
} catch (error) {
next(error);
}
});
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof z.ZodError) {
res.status(400).json({ message: "Invalid request", issues: err.issues });
return;
}
const message = err instanceof Error ? err.message : "Unexpected error";
res.status(500).json({ message });
});
const frontendRoot = join(process.cwd(), "dist/frontend");
if (existsSync(frontendRoot)) {
app.use(express.static(frontendRoot));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(join(frontendRoot, "index.html"));
});
}
return { app, port };
};
if (require.main === module) {
const { app, port } = makeApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`azure-image-chooser listening on ${port}`);
});
}
export { makeApp };

View File

@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import nunjucks from "nunjucks";
import type { ImageSelection, UsageTemplate } from "./types";
const findAppRoot = (): string => {
// Supports running from src/backend (dev) and dist/backend (compiled).
const devPath = join(__dirname, "../../../app");
const buildPath = join(__dirname, "../../app");
try {
readFileSync(join(devPath, "templates.json"), "utf8");
return devPath;
} catch {
return buildPath;
}
};
export class TemplateService {
private readonly appRoot = findAppRoot();
private readonly env = nunjucks.configure(join(this.appRoot, "templates"), {
autoescape: false,
noCache: true
});
private readonly templates: UsageTemplate[] = JSON.parse(
readFileSync(join(this.appRoot, "templates.json"), "utf8")
) as UsageTemplate[];
public getTemplates(): UsageTemplate[] {
return this.templates;
}
public render(templateFile: string, selection: ImageSelection): string {
return this.env.render(templateFile, selection);
}
public buildSkuExport(skus: string[]): string {
return `[\n${skus.map((sku) => `\t\"${sku}\"`).join(",\n")}\n]`;
}
}

View File

@@ -0,0 +1,18 @@
export type LocationOption = {
name: string;
displayName: string;
};
export type UsageTemplate = {
label: string;
language: string;
file: string;
};
export type ImageSelection = {
location: string;
publisher: string;
offer: string;
sku: string;
version: string;
};

View File

@@ -0,0 +1,22 @@
const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
const semverSortKey = (value: string): number[] => value.split(".").map((part) => Number.parseInt(part, 10));
export const sortImageVersionsIfSemantic = (versions: string[]): string[] => {
if (!versions.every((value) => SEMVER_PATTERN.test(value))) {
return versions;
}
return [...versions].sort((a, b) => {
const aParts = semverSortKey(a);
const bParts = semverSortKey(b);
for (let i = 0; i < aParts.length; i += 1) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i];
}
}
return 0;
});
};

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { sortImageVersionsIfSemantic } from "../src/version";
describe("sortImageVersionsIfSemantic", () => {
it("sorts only semantic versions", () => {
const sorted = sortImageVersionsIfSemantic(["1.10.0", "1.2.0", "2.0.0"]);
expect(sorted).toEqual(["1.2.0", "1.10.0", "2.0.0"]);
});
it("returns source order for non-semantic versions", () => {
const original = ["latest", "1.0.0", "beta"];
expect(sortImageVersionsIfSemantic(original)).toEqual(original);
});
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "../../dist/backend",
"types": ["node", "vitest/globals"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Azure Image Chooser</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4355
app-new/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "azure-image-chooser-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.99.2",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^27.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.8",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
CircularProgress,
Container,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
Typography
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api";
import type { SelectionState } from "./types";
const EMPTY_SELECTION: SelectionState = {
location: "",
publisher: "",
offer: "",
sku: "",
version: ""
};
const App = () => {
const [selection, setSelection] = useState<SelectionState>(EMPTY_SELECTION);
const [templateFile, setTemplateFile] = useState("");
const [renderedUsage, setRenderedUsage] = useState("");
const [skuExport, setSkuExport] = useState("");
const [renderError, setRenderError] = useState("");
const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations });
const publishersQuery = useQuery({
queryKey: ["publishers", selection.location],
queryFn: () => api.publishers(selection.location),
enabled: Boolean(selection.location)
});
const offersQuery = useQuery({
queryKey: ["offers", selection.location, selection.publisher],
queryFn: () => api.offers(selection.location, selection.publisher),
enabled: Boolean(selection.location && selection.publisher)
});
const skusQuery = useQuery({
queryKey: ["skus", selection.location, selection.publisher, selection.offer],
queryFn: () => api.skus(selection.location, selection.publisher, selection.offer),
enabled: Boolean(selection.location && selection.publisher && selection.offer)
});
const versionsQuery = useQuery({
queryKey: ["versions", selection.location, selection.publisher, selection.offer, selection.sku],
queryFn: () => api.versions(selection.location, selection.publisher, selection.offer, selection.sku),
enabled: Boolean(selection.location && selection.publisher && selection.offer && selection.sku)
});
const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: api.templates });
useEffect(() => {
setSelection((previous) => ({ ...previous, publisher: "", offer: "", sku: "", version: "" }));
setRenderedUsage("");
setSkuExport("");
}, [selection.location]);
useEffect(() => {
setSelection((previous) => ({ ...previous, offer: "", sku: "", version: "" }));
setRenderedUsage("");
setSkuExport("");
}, [selection.publisher]);
useEffect(() => {
setSelection((previous) => ({ ...previous, sku: "", version: "" }));
setRenderedUsage("");
}, [selection.offer]);
useEffect(() => {
setSelection((previous) => ({ ...previous, version: "" }));
setRenderedUsage("");
}, [selection.sku]);
const canRender = useMemo(
() => Boolean(selection.location && selection.publisher && selection.offer && selection.sku && selection.version && templateFile),
[selection, templateFile]
);
const onRender = async () => {
setRenderError("");
try {
const [usage, skuBlock] = await Promise.all([
api.render(templateFile, selection),
api.skuExport(selection.location, selection.publisher, selection.offer)
]);
setRenderedUsage(usage);
setSkuExport(skuBlock);
} catch (error) {
setRenderError(error instanceof Error ? error.message : "Unexpected render error");
}
};
const loading = locationsQuery.isLoading || templatesQuery.isLoading;
return (
<Box sx={{ minHeight: "100vh", py: 6 }}>
<Container maxWidth="lg">
<Stack spacing={3}>
<Typography variant="h3" sx={{ fontWeight: 700 }}>
Azure Image Chooser
</Typography>
<Typography color="text.secondary">
Select a marketplace image and generate reusable snippets.
</Typography>
{loading ? (
<CircularProgress />
) : (
<Card>
<CardContent>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel id="location-label">Location</InputLabel>
<Select
labelId="location-label"
label="Location"
value={selection.location}
onChange={(event) => setSelection((prev) => ({ ...prev, location: event.target.value }))}
>
{(locationsQuery.data ?? []).map((location) => (
<MenuItem key={location.name} value={location.name}>
{location.displayName}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth disabled={!selection.location}>
<InputLabel id="publisher-label">Publisher</InputLabel>
<Select
labelId="publisher-label"
label="Publisher"
value={selection.publisher}
onChange={(event) => setSelection((prev) => ({ ...prev, publisher: event.target.value }))}
>
{(publishersQuery.data ?? []).map((publisher) => (
<MenuItem key={publisher} value={publisher}>
{publisher}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!selection.publisher}>
<InputLabel id="offer-label">Offer</InputLabel>
<Select
labelId="offer-label"
label="Offer"
value={selection.offer}
onChange={(event) => setSelection((prev) => ({ ...prev, offer: event.target.value }))}
>
{(offersQuery.data ?? []).map((offer) => (
<MenuItem key={offer} value={offer}>
{offer}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!selection.offer}>
<InputLabel id="sku-label">SKU</InputLabel>
<Select
labelId="sku-label"
label="SKU"
value={selection.sku}
onChange={(event) => setSelection((prev) => ({ ...prev, sku: event.target.value }))}
>
{(skusQuery.data ?? []).map((sku) => (
<MenuItem key={sku} value={sku}>
{sku}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!selection.sku}>
<InputLabel id="version-label">Version</InputLabel>
<Select
labelId="version-label"
label="Version"
value={selection.version}
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
>
{(versionsQuery.data ?? []).map((version) => (
<MenuItem key={version} value={version}>
{version}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<FormControl fullWidth>
<InputLabel id="template-label">Usage scenario</InputLabel>
<Select
labelId="template-label"
label="Usage scenario"
value={templateFile}
onChange={(event) => setTemplateFile(event.target.value)}
>
{(templatesQuery.data ?? []).map((template) => (
<MenuItem key={template.file} value={template.file}>
{template.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Button fullWidth variant="contained" sx={{ height: "100%" }} onClick={onRender} disabled={!canRender}>
Generate usage
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
)}
{renderError ? <Alert severity="error">{renderError}</Alert> : null}
{renderedUsage ? (
<Card>
<CardContent>
<Typography variant="h6">Usage snippet</Typography>
<Box component="pre" sx={{ overflowX: "auto", p: 2, bgcolor: "#0f172a", color: "#e2e8f0", borderRadius: 2 }}>
{renderedUsage}
</Box>
</CardContent>
</Card>
) : null}
{skuExport ? (
<Card>
<CardContent>
<Typography variant="h6">Available SKUs</Typography>
<Box component="pre" sx={{ overflowX: "auto", p: 2, bgcolor: "#111827", color: "#d1fae5", borderRadius: 2 }}>
{skuExport}
</Box>
</CardContent>
</Card>
) : null}
</Stack>
</Container>
</Box>
);
};
export default App;

View File

@@ -0,0 +1,46 @@
import type { LocationOption, SelectionState, UsageTemplate } from "./types";
const json = async <T>(path: string): Promise<T> => {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Request failed for ${path}`);
}
return (await response.json()) as T;
};
export const api = {
locations: () => json<LocationOption[]>("/api/locations"),
publishers: (location: string) => json<string[]>(`/api/publishers?location=${encodeURIComponent(location)}`),
offers: (location: string, publisher: string) =>
json<string[]>(`/api/offers?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}`),
skus: (location: string, publisher: string, offer: string) =>
json<string[]>(
`/api/skus?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}`
),
versions: (location: string, publisher: string, offer: string, sku: string) =>
json<string[]>(
`/api/versions?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}&sku=${encodeURIComponent(sku)}`
),
templates: () => json<UsageTemplate[]>("/api/templates"),
render: async (templateFile: string, selection: SelectionState): Promise<string> => {
const response = await fetch("/api/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ templateFile, selection })
});
if (!response.ok) {
throw new Error("Failed to render template");
}
const payload = (await response.json()) as { rendered: string };
return payload.rendered;
},
skuExport: async (location: string, publisher: string, offer: string): Promise<string> => {
const payload = await json<{ rendered: string }>(
`/api/sku-export?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}`
);
return payload.rendered;
}
};

View File

@@ -0,0 +1,33 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
import App from "./App";
const queryClient = new QueryClient();
const theme = createTheme({
palette: {
mode: "light",
primary: { main: "#00695f" },
secondary: { main: "#0d47a1" },
background: {
default: "#f4f8f7",
paper: "#ffffff"
}
},
shape: {
borderRadius: 12
}
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>
);

View File

@@ -0,0 +1,18 @@
export type LocationOption = {
name: string;
displayName: string;
};
export type UsageTemplate = {
label: string;
language: string;
file: string;
};
export type SelectionState = {
location: string;
publisher: string;
offer: string;
sku: string;
version: string;
};

View File

@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider, createTheme } from "@mui/material";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import App from "../src/App";
const originalFetch = globalThis.fetch;
const createResponse = (payload: unknown) =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" }
});
describe("App", () => {
beforeEach(() => {
globalThis.fetch = vi.fn((url: string) => {
if (url === "/api/locations") {
return Promise.resolve(createResponse([{ name: "westeurope", displayName: "West Europe" }]));
}
if (url === "/api/templates") {
return Promise.resolve(createResponse([{ label: "Azure CLI", language: "shell", file: "shell.tpl" }]));
}
return Promise.resolve(createResponse([]));
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("renders application heading", async () => {
const client = new QueryClient();
const theme = createTheme();
render(
<QueryClientProvider client={client}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</QueryClientProvider>
);
expect(await screen.findByText("Azure Image Chooser")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"types": ["vite/client", "vitest/globals"]
},
"include": ["src", "test", "vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "../../dist/frontend",
emptyOutDir: true
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true
}
}
}
});

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"]
}
});

4
entrypoint.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
set -eu
exec node dist/backend/server.js

103
migration-plan/plan.md Normal file
View File

@@ -0,0 +1,103 @@
## Plan: React + Vite Rewrite of Azure Image Chooser
Rewrite the current Streamlit application into a two-tier web app (React + Vite frontend and Node.js backend) while preserving core image-discovery behavior (location -> publisher -> offer -> SKU -> version) and generated usage outputs, and redesigning UX and template architecture, with strict non-destructive workspace constraints.
**Hard constraints**
1. Existing workspace files must remain untouched, except .gitignore.
2. All new application source must live under src/.
3. Build artifacts must be emitted only to dist/.
4. A new Dockerfile and entrypoint.sh must be added at the workspace root.
5. The container runtime must use Node.js 24 LTS on Debian trixie-slim (node:24-trixie-slim).
6. All new source code must be TypeScript and use the TypeScript 6 transpiler (latest revision).
7. Frontend UI must use plain Material UI v9.x.x.
8. All MUI-related questions and implementation decisions must be resolved using mui-mcp as the primary source/tool.
9. Frontend must use the latest stable React line (React 19.2.x), with no legacy compatibility or migration work included.
10. Docker is not available in the target environment; container lifecycle operations must use Apple-sponsored container CLI only.
**Steps**
1. Phase 0 - Planning artifact handoff
1. After plan mode is turned off, copy plan artifacts to workspace directory migration-plan/ for future reference:
- migration-plan/plan.md
- migration-plan/version-manifest.md
2. Keep session/repo memory files as source-of-truth backups.
1. Phase 1 - Baseline and migration contract
1. Capture current functional contract from existing app behavior in app/image-chooser.py: cascading selectors, semantic version sorting, empty-state behavior, and SKU export format.
2. Define backend API contract for frontend consumption: locations, publishers, offers, skus, versions, templates, rendered output, and SKU export endpoints, including error payloads.
3. Lock frontend framework prerequisites from current MUI status before scaffolding: Material UI v9.x.x, React 19.2.x (latest stable), Emotion styling engine defaults, and required companion packages.
4. Explicitly exclude legacy React compatibility paths and migration workarounds from scope (no legacy shims, no backward-compat migration tracks).
5. Define target repository layout under src/ for frontend and backend packages, with dist/ as the fixed artifact directory.
6. Define non-destructive execution guardrails: no edits to legacy files except .gitignore updates for node_modules and artifact outputs.
1. Phase 2 - Node backend in src
1. Scaffold backend under src/backend in TypeScript (mandatory) with modules for Azure compute discovery, template catalog, rendering, and validation, compiled with TypeScript 6.
2. Implement Azure integration equivalent to current data loaders, including region_type=Physical filtering and conditional semantic version sorting when all versions match X.Y.Z.
3. Implement endpoint caching (TTL by endpoint+params) and structured logging.
4. Implement redesigned template subsystem with metadata model and render endpoint.
5. Add backend tests under src/backend aligned to the API contract.
1. Phase 3 - React + Vite frontend in src
1. Scaffold frontend under src/frontend using Vite with TypeScript (mandatory), configure output directory to dist/, compile with TypeScript 6, use React 19.2.x (latest stable), and use plain Material UI v9.x.x as the UI component library.
2. Build cascading async selectors with deterministic reset behavior (location -> publisher -> offer -> sku -> version).
3. Implement API-driven state management (for example TanStack Query) with loading, empty, and error states.
4. Implement usage output UI with template selection, rendered snippet display, copy action, and SKU export display, using plain Material UI v9.x.x components and styling primitives (Emotion-based engine).
5. Add frontend tests under src/frontend for cascade behavior and output rendering.
1. Phase 4 - Root runtime wrapper and cutover
1. Add new root Dockerfile that builds/runs the new src-based application stack on Node.js 24 LTS with Debian trixie-slim (node:24-trixie-slim), and ensure runtime commands are documented/executed via Apple container CLI (not Docker).
2. Add new root entrypoint.sh that starts the new runtime components.
3. Update .gitignore for node_modules, dist artifacts, and any new temporary outputs.
4. Validate core behavior parity against Streamlit baseline using representative selections and generated outputs.
5. Keep legacy assets in place (untouched) during and after migration unless separately approved for cleanup.
**Parallelism and dependencies**
1. Phase 0 runs first and gates execution handoff artifacts in workspace.
2. Phase 1 blocks implementation.
3. After API contract freeze, backend (Phase 2) and frontend scaffold (Phase 3) can run in parallel.
4. Phase 4 depends on stable frontend/backend run commands and artifact paths.
**Relevant files**
- /Users/slawek/src/azure-image-chooser/app/image-chooser.py - source-of-truth behavior reference only; do not modify.
- /Users/slawek/src/azure-image-chooser/app/templates.json - current template catalog reference only; do not modify.
- /Users/slawek/src/azure-image-chooser/app/templates/arm_vm.jsonc - output reference only; do not modify.
- /Users/slawek/src/azure-image-chooser/app/templates/azurerm_hcl.tpl - output reference only; do not modify.
- /Users/slawek/src/azure-image-chooser/app/templates/shell.tpl - output reference only; do not modify.
- /Users/slawek/src/azure-image-chooser/.gitignore - only existing file allowed to be changed.
- /Users/slawek/src/azure-image-chooser/src/ - new source tree for rewritten application.
- /Users/slawek/src/azure-image-chooser/dist/ - artifact output directory.
- /Users/slawek/src/azure-image-chooser/Dockerfile - new root runtime image definition.
- /Users/slawek/src/azure-image-chooser/entrypoint.sh - new root runtime entrypoint.
**Verification**
1. Workspace contains migration-plan/plan.md and migration-plan/version-manifest.md after plan mode is off.
2. Git diff check confirms only new files plus .gitignore modifications; no edits in legacy files.
3. Backend tests pass for API contract, validation, filtering, sorting, and rendering.
4. Type-check/transpile verification passes with TypeScript 6 for both backend and frontend packages.
5. Frontend tests pass for selector cascade and output rendering.
6. Frontend dependency and implementation checks confirm plain Material UI v9.x.x is used (no alternate UI framework).
7. Verification confirms MUI installation prerequisites are met for React 19.2.x and Emotion packages.
8. Verification confirms there are no legacy compatibility layers or migration-specific workarounds in frontend dependencies/configuration.
9. Local run verifies UI flow and generated outputs match baseline behavior for representative scenarios.
10. Image build inspection via Apple container CLI confirms the runtime base image is Node.js 24 LTS on Debian trixie-slim (node:24-trixie-slim).
11. Artifact generation confirms outputs land only in dist/ directory.
**Decisions**
- Use Node.js backend (confirmed).
- Keep only core functionality unchanged; UX and architecture can be redesigned (confirmed).
- Redesign template system now (confirmed).
- Enforce non-destructive migration: existing files untouched except .gitignore (confirmed).
- Place all new application code in src/ and add new root Dockerfile + entrypoint.sh (confirmed).
- Use Node.js 24 LTS on Debian trixie-slim as container runtime base image (confirmed).
- Use TypeScript-only source with TypeScript 6 transpiler across backend and frontend (confirmed).
- Use plain Material UI v9.x.x for frontend UI implementation (confirmed).
- Use mui-mcp as the primary tool/source for all MUI-related implementation questions (confirmed).
- Use latest stable React (19.2.x at planning time) and exclude legacy compatibility/migration paths (confirmed).
- Use Apple container CLI (macOS 26, Apple silicon) for container operations; no Docker dependency (confirmed).
**Scope boundaries**
- In scope: functional core parity for image lookup and output generation, new frontend/backend architecture in src/, root runtime wrappers, and .gitignore updates.
- Out of scope: modifying existing legacy app/ and terraform/ files, removing legacy code, or altering old deployment definitions.
**Further Considerations**
1. Artifact output is fixed to dist/ for consistency with the chosen convention.
2. If backend and frontend are separate runtime processes, define entrypoint process model early (single-process with static serve vs process manager).

View File

@@ -0,0 +1,57 @@
## Version Manifest: React + Vite + MUI Rewrite
### Environment and container tooling
- macOS: 26 (host requirement)
- Container CLI: apple/container (latest release observed: 0.11.0)
- Container runtime command: container (Docker unavailable by constraint)
- Base image: node:24-trixie-slim (Node 24 LTS on Debian trixie-slim; OCI-compatible)
### Frontend stack
- Node.js runtime: 24.x LTS
- React: 19.2.5
- React DOM: 19.2.5
- TypeScript: 6.0.3
- Vite: 8.0.8
- @vitejs/plugin-react: 6.0.1
- @types/react: 19.2.14
- @types/react-dom: 19.2.3
- Material UI core: @mui/material 9.0.0
- Material UI styling engine: @emotion/react 11.14.0, @emotion/styled 11.14.1
- Optional state-fetching utility selected in plan: @tanstack/react-query 5.99.2
### Backend stack (planned)
- Node.js runtime: 24.x LTS
- Express: 5.2.1
- Zod: 4.3.6
- TypeScript: 6.0.3
### MUI knowledge/tooling
- MUI docs index for LLM consumption: https://mui.com/material-ui/llms.txt
- MUI MCP package invocation model: npx -y @mui/mcp@latest
## Compatibility Audit
### Hard compatibility checks
1. Node 24 vs Vite 8: compatible (Vite requires Node 20.19+ or 22.12+).
2. React 19.2.x vs MUI 9: compatible (MUI supports React ^17 || ^18 || ^19).
3. React 19.2.x vs react-dom 19.2.5: compatible (same major/minor line).
4. TypeScript 6 vs MUI 9: compatible (MUI requires TS >=4.9).
5. Node 24 vs Express 5.2.1: compatible (Express requires Node >=18).
6. Apple container vs Dockerfile workflow: compatible (container build supports Dockerfile and Containerfile).
### Environment-specific incompatibilities / risks
1. Docker command compatibility: incompatible by environment constraint.
- Any script that assumes docker build/run must be translated to container build/run.
2. Apple container stability: medium risk.
- Project is pre-1.0 and may introduce breaking changes in minor releases.
- Mitigation: pin CLI release during execution and document upgrade policy.
3. Slim Debian runtime package availability: low-to-medium operational risk.
- trixie-slim is smaller than full images and may omit convenience packages.
- Mitigation: install only required OS packages explicitly and validate build/runtime dependencies.
4. @mui/mcp@latest floating version: reproducibility risk.
- Mitigation: pin exact MCP package version for CI/repeatable local behavior once selected.
### Migration-policy compatibility with user constraints
1. Latest stable React only: satisfied by React 19.2.x.
2. No legacy/migration support tracks: satisfied; no React 18 fallback assumptions included.
3. MUI usage policy: satisfied with plain Material UI v9.x.x and mui-mcp-first guidance.