Migrated to NodeJS/Vite/Express/Material UI 2 #1
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,3 +17,7 @@
|
|||||||
|
|
||||||
# MacOS Finder files
|
# MacOS Finder files
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
|
||||||
|
# Node/React rewrite outputs
|
||||||
|
**/node_modules
|
||||||
|
/dist
|
||||||
|
|||||||
32
Dockerfile
Normal file
32
Dockerfile
Normal 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
3244
app-new/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
app-new/backend/package.json
Normal file
30
app-new/backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app-new/backend/src/azure-service.ts
Normal file
117
app-new/backend/src/azure-service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app-new/backend/src/cache.ts
Normal file
29
app-new/backend/src/cache.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app-new/backend/src/server.ts
Normal file
149
app-new/backend/src/server.ts
Normal 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 };
|
||||||
41
app-new/backend/src/template-service.ts
Normal file
41
app-new/backend/src/template-service.ts
Normal 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]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app-new/backend/src/types.ts
Normal file
18
app-new/backend/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
22
app-new/backend/src/version.ts
Normal file
22
app-new/backend/src/version.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
14
app-new/backend/test/version.test.ts
Normal file
14
app-new/backend/test/version.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
app-new/backend/tsconfig.json
Normal file
16
app-new/backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
12
app-new/frontend/index.html
Normal file
12
app-new/frontend/index.html
Normal 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
4355
app-new/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
app-new/frontend/package.json
Normal file
31
app-new/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
267
app-new/frontend/src/App.tsx
Normal file
267
app-new/frontend/src/App.tsx
Normal 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;
|
||||||
46
app-new/frontend/src/api.ts
Normal file
46
app-new/frontend/src/api.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
33
app-new/frontend/src/main.tsx
Normal file
33
app-new/frontend/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
18
app-new/frontend/src/types.ts
Normal file
18
app-new/frontend/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
48
app-new/frontend/test/app.test.tsx
Normal file
48
app-new/frontend/test/app.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
1
app-new/frontend/test/setup.ts
Normal file
1
app-new/frontend/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
15
app-new/frontend/tsconfig.json
Normal file
15
app-new/frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
19
app-new/frontend/vite.config.ts
Normal file
19
app-new/frontend/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
8
app-new/frontend/vitest.config.ts
Normal file
8
app-new/frontend/vitest.config.ts
Normal 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
4
entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
exec node dist/backend/server.js
|
||||||
103
migration-plan/plan.md
Normal file
103
migration-plan/plan.md
Normal 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).
|
||||||
57
migration-plan/version-manifest.md
Normal file
57
migration-plan/version-manifest.md
Normal 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.
|
||||||
Reference in New Issue
Block a user