Moved the NodeJS version of the application to the app/ directory.

This commit is contained in:
2026-04-20 07:17:30 +02:00
parent 0d12f24dec
commit 9100f71ab5
30 changed files with 0 additions and 0 deletions

34
app/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM node:24-trixie-slim AS build
WORKDIR /app
COPY app-new/backend/package*.json backend/
COPY app-new/frontend/package*.json frontend/
RUN cd backend && npm ci
RUN cd frontend && npm ci
COPY app-new .
RUN cd backend && npm run build
RUN cd frontend && npm run build
RUN cd backend && npm prune --omit=dev
FROM node:24-trixie-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=build /app/dist dist
COPY --from=build /app/templates templates
COPY --from=build /app/templates.json templates.json
COPY --from=build /app/backend/node_modules dist/backend/node_modules
WORKDIR /app
COPY entrypoint.sh entrypoint.sh
COPY healthcheck.js healthcheck.js
RUN chmod +x entrypoint.sh
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["node", "/app/healthcheck.js"]
ENTRYPOINT ["./entrypoint.sh"]

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

File diff suppressed because it is too large Load Diff

30
app/backend/package.json Normal file
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,130 @@
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 ?? ""}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch Azure locations: ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as {
value?: Array<{ name?: string; displayName?: string; metadata?: { regionType?: string } }>;
};
const allLocations = (payload.value ?? [])
.filter((loc) => Boolean(loc.name))
.map((loc) => ({
name: loc.name as string,
displayName: loc.displayName ?? (loc.name as string),
regionType: loc.metadata?.regionType
}));
const physical = allLocations.filter((loc) => loc.regionType?.toLowerCase() === "physical");
const locations = (physical.length > 0 ? physical : allLocations).map((loc) => ({
name: loc.name,
displayName: loc.displayName
}));
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).sort((a, b) => a.localeCompare(b));
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).sort((a, b) => a.localeCompare(b));
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).sort((a, b) => a.localeCompare(b));
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));
}
}

29
app/backend/src/cache.ts Normal file
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
});
}
}

195
app/backend/src/server.ts Normal file
View File

@@ -0,0 +1,195 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import cors from "cors";
import express from "express";
import { z } from "zod";
import { AzureImageService } from "./azure-service";
import { TemplateService } from "./template-service";
const findAppNewRoot = (): string => {
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
for (const candidate of candidates) {
if (existsSync(join(candidate, "templates.json"))) {
return candidate;
}
}
throw new Error("Unable to resolve app-new root");
};
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());
const azure = subscriptionId ? new AzureImageService(subscriptionId) : null;
const templates = new TemplateService();
app.get("/api/health", (_req, res) => {
if (!subscriptionId) {
res.status(500).json({
status: "error",
message: "Missing AZURE_SUBSCRIPTION_ID"
});
return;
}
res.json({ status: "ok" });
});
const requireAzure = (): AzureImageService => {
if (!azure) {
throw new Error("Missing AZURE_SUBSCRIPTION_ID");
}
return azure;
};
app.get("/api/locations", async (_req, res, next) => {
try {
res.json(await requireAzure().getLocations());
} catch (error) {
next(error);
}
});
app.get("/api/publishers", async (req, res, next) => {
try {
const { location } = queryLocation.parse(req.query);
res.json(await requireAzure().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 requireAzure().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 requireAzure().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 requireAzure().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 requireAzure().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(findAppNewRoot(), "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();
const server = app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`azure-image-chooser listening on ${port}`);
});
let shuttingDown = false;
const shutdown = (signal: NodeJS.Signals) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
// eslint-disable-next-line no-console
console.log(`received ${signal}, shutting down`);
server.close((error) => {
if (error) {
// eslint-disable-next-line no-console
console.error("graceful shutdown failed", error);
process.exit(1);
}
process.exit(0);
});
// Force-exit if connections do not close in time.
setTimeout(() => {
// eslint-disable-next-line no-console
console.error("shutdown timeout reached, forcing exit");
process.exit(1);
}, 10_000).unref();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
export { makeApp };

View File

@@ -0,0 +1,41 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import nunjucks from "nunjucks";
import type { ImageSelection, UsageTemplate } from "./types";
const findAppNewRoot = (): string => {
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
for (const candidate of candidates) {
if (existsSync(join(candidate, "templates.json"))) {
return candidate;
}
}
throw new Error("Unable to resolve app-new template root");
};
export class TemplateService {
private readonly appNewRoot = findAppNewRoot();
private readonly env = nunjucks.configure(join(this.appNewRoot, "templates"), {
autoescape: false,
noCache: true
});
private readonly templates: UsageTemplate[] = JSON.parse(
readFileSync(join(this.appNewRoot, "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]`;
}
}

18
app/backend/src/types.ts Normal file
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].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
}
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("sorts non-semantic versions naturally", () => {
const original = ["latest", "1.0.0", "beta"];
expect(sortImageVersionsIfSemantic(original)).toEqual(["1.0.0", "beta", "latest"]);
});
});

16
app/backend/tsconfig.json Normal file
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"]
}

4
app/entrypoint.sh Executable file
View File

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

12
app/frontend/index.html Normal file
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>

4339
app/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
app/frontend/package.json Normal file
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"
}
}

292
app/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,292 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Autocomplete,
Box,
Button,
Card,
CardContent,
CircularProgress,
Container,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
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 healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
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 publishers = useMemo(
() => [...(publishersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
[publishersQuery.data]
);
const offers = useMemo(
() => [...(offersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
[offersQuery.data]
);
const skus = useMemo(
() => [...(skusQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
[skusQuery.data]
);
const versions = useMemo(
() => [...(versionsQuery.data ?? [])].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })),
[versionsQuery.data]
);
const containsFilter = (options: string[], inputValue: string) => {
const query = inputValue.trim().toLowerCase();
if (!query) {
return options;
}
return options.filter((option) => option.toLowerCase().includes(query));
};
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;
const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : "";
const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID");
const locationsError = locationsQuery.error instanceof Error ? locationsQuery.error.message : "Failed to load locations";
return (
<Box sx={{ minHeight: "100vh", py: 4 }}>
<Container maxWidth="lg">
<Stack spacing={3}>
<Typography variant="h4" sx={{ fontWeight: 500 }}>
Azure Image Chooser
</Typography>
<Typography color="text.secondary">
Select a marketplace image and generate reusable snippets.
</Typography>
{appNotConfigured ? (
<Alert severity="warning">
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
</Alert>
) : null}
{loading ? (
<CircularProgress />
) : locationsQuery.isError ? (
<Alert severity={appNotConfigured ? "warning" : "error"}>{locationsError}</Alert>
) : (
<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 }}>
<Autocomplete
options={publishers}
value={selection.publisher || null}
disabled={!selection.location}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, publisher: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="Publisher" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Autocomplete
options={offers}
value={selection.offer || null}
disabled={!selection.publisher}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, offer: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="Offer" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Autocomplete
options={skus}
value={selection.sku || null}
disabled={!selection.offer}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, sku: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="SKU" />}
/>
</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 }))}
>
{versions.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%" }} color="primary" 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: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
>
{renderedUsage}
</Box>
</CardContent>
</Card>
) : null}
{skuExport ? (
<Card>
<CardContent>
<Typography variant="h6">Available SKUs</Typography>
<Box
component="pre"
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
>
{skuExport}
</Box>
</CardContent>
</Card>
) : null}
</Stack>
</Container>
</Box>
);
};
export default App;

55
app/frontend/src/api.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { LocationOption, SelectionState, UsageTemplate } from "./types";
const json = async <T>(path: string): Promise<T> => {
const response = await fetch(path);
if (!response.ok) {
let details = "";
try {
const payload = (await response.json()) as { message?: string };
details = payload.message ?? "";
} catch {
// Ignore non-JSON error payloads.
}
throw new Error(details || `Request failed for ${path} (${response.status})`);
}
return (await response.json()) as T;
};
export const api = {
health: () => json<{ status: string; message?: string }>("/api/health"),
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;
}
};

16
app/frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CssBaseline } from "@mui/material";
import App from "./App";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<CssBaseline />
<App />
</QueryClientProvider>
</StrictMode>
);

18
app/frontend/src/types.ts Normal file
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,57 @@
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({
defaultOptions: {
queries: {
retry: false,
gcTime: 0
}
}
});
const theme = createTheme();
const view = render(
<QueryClientProvider client={client}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</QueryClientProvider>
);
expect(await screen.findByText("Azure Image Chooser")).toBeInTheDocument();
view.unmount();
client.clear();
});
});

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"]
}
});

39
app/healthcheck.js Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
"use strict";
const http = require("node:http");
const port = Number.parseInt(process.env.PORT || "3000", 10);
const path = process.env.HEALTHCHECK_PATH || "/api/health";
const timeoutMs = Number.parseInt(process.env.HEALTHCHECK_TIMEOUT_MS || "3000", 10);
const req = http.request(
{
host: "127.0.0.1",
port,
path,
method: "GET",
timeout: timeoutMs
},
(res) => {
// Drain the response so the socket can close cleanly.
res.resume();
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) {
process.exit(0);
return;
}
process.exit(1);
}
);
req.on("timeout", () => {
req.destroy(new Error("healthcheck timeout"));
});
req.on("error", () => {
process.exit(1);
});
req.end();

22
app/templates.json Normal file
View File

@@ -0,0 +1,22 @@
[
{
"label": "Terraform VM image reference",
"language": "hcl",
"file": "azurerm_hcl.tpl"
},
{
"label": "Azure CLI",
"language": "shell",
"file": "shell.tpl"
},
{
"label": "Azure Resource Manager Template",
"language": "json",
"file": "arm_vm.jsonc"
},
{
"label": "Bicep VM image reference",
"language": "bicep",
"file": "bicep_vm.tpl"
}
]

View File

@@ -0,0 +1,17 @@
{
// This is a partial Azure virtual machine resource template.
"type": "Microsoft.Compute/virtualMachines",
"apiVersion": "2022-03-01",
"name": "example-vm",
"location": "westeurope",
"properties": {
"storageProfile": {
"imageReference": {
"publisher": "{{ publisher }}",
"offer": "{{ offer }}",
"sku": "{{ sku }}",
"version": "{{ version }}"
}
}
}
}

View File

@@ -0,0 +1,6 @@
source_image_reference = {
publisher = "{{ publisher }}"
offer = "{{ offer }}"
sku = "{{ sku }}"
version = "{{ version }}"
}

View File

@@ -0,0 +1,14 @@
resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-03-01' = {
name: 'example-vm'
location: resourceGroup().location
properties: {
storageProfile: {
imageReference: {
publisher: '{{ publisher }}'
offer: '{{ offer }}'
sku: '{{ sku }}'
version: '{{ version }}'
}
}
}
}

1
app/templates/shell.tpl Normal file
View File

@@ -0,0 +1 @@
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}