Fixes to NodeJS version.
This commit is contained in:
29
Dockerfile
29
Dockerfile
@@ -1,30 +1,29 @@
|
|||||||
FROM node:24-trixie-slim AS build
|
FROM node:24-trixie-slim AS build
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /app-new
|
||||||
|
|
||||||
COPY app-new/backend/package*.json app-new/backend/
|
COPY app-new/backend/package*.json backend/
|
||||||
COPY app-new/frontend/package*.json app-new/frontend/
|
COPY app-new/frontend/package*.json frontend/
|
||||||
RUN cd app-new/backend && npm install
|
RUN cd backend && npm install
|
||||||
RUN cd app-new/frontend && npm install
|
RUN cd frontend && npm install
|
||||||
|
|
||||||
COPY app-new app-new
|
COPY app-new .
|
||||||
COPY app/templates app/templates
|
|
||||||
COPY app/templates.json app/templates.json
|
|
||||||
|
|
||||||
RUN cd app-new/backend && npm run build
|
RUN cd backend && npm run build
|
||||||
RUN cd app-new/frontend && npm run build
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
FROM node:24-trixie-slim AS runtime
|
FROM node:24-trixie-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /app-new
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
COPY app-new/backend/package*.json app-new/backend/
|
COPY app-new/backend/package*.json backend/
|
||||||
RUN cd app-new/backend && npm install --omit=dev
|
RUN cd backend && npm install --omit=dev
|
||||||
|
|
||||||
COPY --from=build /workspace/dist dist
|
COPY --from=build /app-new/dist dist
|
||||||
COPY --from=build /workspace/app app
|
COPY --from=build /app-new/templates templates
|
||||||
|
COPY --from=build /app-new/templates.json templates.json
|
||||||
COPY entrypoint.sh entrypoint.sh
|
COPY entrypoint.sh entrypoint.sh
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"start": "node ../../dist/backend/server.js",
|
"start": "node ../dist/backend/server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -32,13 +32,26 @@ export class AzureImageService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = (await response.json()) as { value?: Array<{ name?: string; displayName?: string; metadata?: { regionType?: string } }> };
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch Azure locations: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const locations = (payload.value ?? [])
|
const payload = (await response.json()) as {
|
||||||
.filter((loc) => loc.metadata?.regionType === "Physical" && Boolean(loc.name))
|
value?: Array<{ name?: string; displayName?: string; metadata?: { regionType?: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allLocations = (payload.value ?? [])
|
||||||
|
.filter((loc) => Boolean(loc.name))
|
||||||
.map((loc) => ({
|
.map((loc) => ({
|
||||||
name: loc.name as string,
|
name: loc.name as string,
|
||||||
displayName: loc.displayName ?? (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));
|
locations.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
@@ -54,7 +67,7 @@ export class AzureImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.computeClient.virtualMachineImages.listPublishers(location);
|
const response = await this.computeClient.virtualMachineImages.listPublishers(location);
|
||||||
const publishers = this.extractNames(response);
|
const publishers = this.extractNames(response).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
this.cache.set(cacheKey, publishers, CACHE_TTL_MS);
|
this.cache.set(cacheKey, publishers, CACHE_TTL_MS);
|
||||||
return publishers;
|
return publishers;
|
||||||
@@ -68,7 +81,7 @@ export class AzureImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.computeClient.virtualMachineImages.listOffers(location, publisher);
|
const response = await this.computeClient.virtualMachineImages.listOffers(location, publisher);
|
||||||
const offers = this.extractNames(response);
|
const offers = this.extractNames(response).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
this.cache.set(cacheKey, offers, CACHE_TTL_MS);
|
this.cache.set(cacheKey, offers, CACHE_TTL_MS);
|
||||||
return offers;
|
return offers;
|
||||||
@@ -82,7 +95,7 @@ export class AzureImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.computeClient.virtualMachineImages.listSkus(location, publisher, offer);
|
const response = await this.computeClient.virtualMachineImages.listSkus(location, publisher, offer);
|
||||||
const skus = this.extractNames(response);
|
const skus = this.extractNames(response).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
this.cache.set(cacheKey, skus, CACHE_TTL_MS);
|
this.cache.set(cacheKey, skus, CACHE_TTL_MS);
|
||||||
return skus;
|
return skus;
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import "dotenv/config";
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AzureImageService } from "./azure-service";
|
import { AzureImageService } from "./azure-service";
|
||||||
import { TemplateService } from "./template-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 queryLocation = z.object({ location: z.string().min(1) });
|
||||||
const queryOffer = z.object({ location: z.string().min(1), publisher: 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 querySku = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1) });
|
||||||
@@ -127,7 +138,7 @@ const makeApp = () => {
|
|||||||
res.status(500).json({ message });
|
res.status(500).json({ message });
|
||||||
});
|
});
|
||||||
|
|
||||||
const frontendRoot = join(process.cwd(), "dist/frontend");
|
const frontendRoot = join(findAppNewRoot(), "dist/frontend");
|
||||||
if (existsSync(frontendRoot)) {
|
if (existsSync(frontendRoot)) {
|
||||||
app.use(express.static(frontendRoot));
|
app.use(express.static(frontendRoot));
|
||||||
app.get(/^(?!\/api).*/, (_req, res) => {
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import nunjucks from "nunjucks";
|
import nunjucks from "nunjucks";
|
||||||
import type { ImageSelection, UsageTemplate } from "./types";
|
import type { ImageSelection, UsageTemplate } from "./types";
|
||||||
|
|
||||||
const findAppRoot = (): string => {
|
const findAppNewRoot = (): string => {
|
||||||
// Supports running from src/backend (dev) and dist/backend (compiled).
|
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
||||||
const devPath = join(__dirname, "../../../app");
|
|
||||||
const buildPath = join(__dirname, "../../app");
|
for (const candidate of candidates) {
|
||||||
try {
|
if (existsSync(join(candidate, "templates.json"))) {
|
||||||
readFileSync(join(devPath, "templates.json"), "utf8");
|
return candidate;
|
||||||
return devPath;
|
|
||||||
} catch {
|
|
||||||
return buildPath;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to resolve app-new template root");
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
private readonly appRoot = findAppRoot();
|
private readonly appNewRoot = findAppNewRoot();
|
||||||
|
|
||||||
private readonly env = nunjucks.configure(join(this.appRoot, "templates"), {
|
private readonly env = nunjucks.configure(join(this.appNewRoot, "templates"), {
|
||||||
autoescape: false,
|
autoescape: false,
|
||||||
noCache: true
|
noCache: true
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly templates: UsageTemplate[] = JSON.parse(
|
private readonly templates: UsageTemplate[] = JSON.parse(
|
||||||
readFileSync(join(this.appRoot, "templates.json"), "utf8")
|
readFileSync(join(this.appNewRoot, "templates.json"), "utf8")
|
||||||
) as UsageTemplate[];
|
) as UsageTemplate[];
|
||||||
|
|
||||||
public getTemplates(): UsageTemplate[] {
|
public getTemplates(): UsageTemplate[] {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const semverSortKey = (value: string): number[] => value.split(".").map((part) =
|
|||||||
|
|
||||||
export const sortImageVersionsIfSemantic = (versions: string[]): string[] => {
|
export const sortImageVersionsIfSemantic = (versions: string[]): string[] => {
|
||||||
if (!versions.every((value) => SEMVER_PATTERN.test(value))) {
|
if (!versions.every((value) => SEMVER_PATTERN.test(value))) {
|
||||||
return versions;
|
return [...versions].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...versions].sort((a, b) => {
|
return [...versions].sort((a, b) => {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ describe("sortImageVersionsIfSemantic", () => {
|
|||||||
expect(sorted).toEqual(["1.2.0", "1.10.0", "2.0.0"]);
|
expect(sorted).toEqual(["1.2.0", "1.10.0", "2.0.0"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns source order for non-semantic versions", () => {
|
it("sorts non-semantic versions naturally", () => {
|
||||||
const original = ["latest", "1.0.0", "beta"];
|
const original = ["latest", "1.0.0", "beta"];
|
||||||
expect(sortImageVersionsIfSemantic(original)).toEqual(original);
|
expect(sortImageVersionsIfSemantic(original)).toEqual(["1.0.0", "beta", "latest"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "../../dist/backend",
|
"outDir": "../dist/backend",
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
107
app-new/dist/backend/azure-service.js
vendored
Normal file
107
app-new/dist/backend/azure-service.js
vendored
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.AzureImageService = void 0;
|
||||||
|
const arm_compute_1 = require("@azure/arm-compute");
|
||||||
|
const identity_1 = require("@azure/identity");
|
||||||
|
const cache_1 = require("./cache");
|
||||||
|
const version_1 = require("./version");
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
class AzureImageService {
|
||||||
|
subscriptionId;
|
||||||
|
credential = new identity_1.DefaultAzureCredential();
|
||||||
|
computeClient;
|
||||||
|
cache = new cache_1.MemoryCache();
|
||||||
|
constructor(subscriptionId) {
|
||||||
|
this.subscriptionId = subscriptionId;
|
||||||
|
this.computeClient = new arm_compute_1.ComputeManagementClient(this.credential, subscriptionId);
|
||||||
|
}
|
||||||
|
async getLocations() {
|
||||||
|
const cacheKey = "locations";
|
||||||
|
const cached = this.cache.get(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());
|
||||||
|
const allLocations = (payload.value ?? [])
|
||||||
|
.filter((loc) => Boolean(loc.name))
|
||||||
|
.map((loc) => ({
|
||||||
|
name: loc.name,
|
||||||
|
displayName: loc.displayName ?? loc.name,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
async getPublishers(location) {
|
||||||
|
const cacheKey = `publishers:${location}`;
|
||||||
|
const cached = this.cache.get(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;
|
||||||
|
}
|
||||||
|
async getOffers(location, publisher) {
|
||||||
|
const cacheKey = `offers:${location}:${publisher}`;
|
||||||
|
const cached = this.cache.get(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;
|
||||||
|
}
|
||||||
|
async getSkus(location, publisher, offer) {
|
||||||
|
const cacheKey = `skus:${location}:${publisher}:${offer}`;
|
||||||
|
const cached = this.cache.get(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;
|
||||||
|
}
|
||||||
|
async getVersions(location, publisher, offer, sku) {
|
||||||
|
const cacheKey = `versions:${location}:${publisher}:${offer}:${sku}`;
|
||||||
|
const cached = this.cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const response = await this.computeClient.virtualMachineImages.list(location, publisher, offer, sku);
|
||||||
|
const versions = this.extractNames(response);
|
||||||
|
const sorted = (0, version_1.sortImageVersionsIfSemantic)(versions);
|
||||||
|
this.cache.set(cacheKey, sorted, CACHE_TTL_MS);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
extractNames(source) {
|
||||||
|
const items = Array.isArray(source)
|
||||||
|
? source
|
||||||
|
: typeof source === "object" && source !== null && "value" in source && Array.isArray(source.value)
|
||||||
|
? source.value
|
||||||
|
: [];
|
||||||
|
return items
|
||||||
|
.map((item) => (typeof item === "object" && item !== null && "name" in item ? item.name : undefined))
|
||||||
|
.filter((value) => Boolean(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.AzureImageService = AzureImageService;
|
||||||
24
app-new/dist/backend/cache.js
vendored
Normal file
24
app-new/dist/backend/cache.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.MemoryCache = void 0;
|
||||||
|
class MemoryCache {
|
||||||
|
store = new Map();
|
||||||
|
get(key) {
|
||||||
|
const hit = this.store.get(key);
|
||||||
|
if (!hit) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Date.now() >= hit.expiresAt) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return hit.value;
|
||||||
|
}
|
||||||
|
set(key, value, ttlMs) {
|
||||||
|
this.store.set(key, {
|
||||||
|
value,
|
||||||
|
expiresAt: Date.now() + ttlMs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.MemoryCache = MemoryCache;
|
||||||
148
app-new/dist/backend/server.js
vendored
Normal file
148
app-new/dist/backend/server.js
vendored
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.makeApp = void 0;
|
||||||
|
const node_fs_1 = require("node:fs");
|
||||||
|
const node_path_1 = require("node:path");
|
||||||
|
const cors_1 = __importDefault(require("cors"));
|
||||||
|
const express_1 = __importDefault(require("express"));
|
||||||
|
const zod_1 = require("zod");
|
||||||
|
const azure_service_1 = require("./azure-service");
|
||||||
|
const template_service_1 = require("./template-service");
|
||||||
|
const findAppNewRoot = () => {
|
||||||
|
const candidates = [(0, node_path_1.join)(__dirname, "../../.."), (0, node_path_1.join)(__dirname, "../..")];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(candidate, "templates.json"))) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unable to resolve app-new root");
|
||||||
|
};
|
||||||
|
const queryLocation = zod_1.z.object({ location: zod_1.z.string().min(1) });
|
||||||
|
const queryOffer = zod_1.z.object({ location: zod_1.z.string().min(1), publisher: zod_1.z.string().min(1) });
|
||||||
|
const querySku = zod_1.z.object({ location: zod_1.z.string().min(1), publisher: zod_1.z.string().min(1), offer: zod_1.z.string().min(1) });
|
||||||
|
const queryVersion = zod_1.z.object({ location: zod_1.z.string().min(1), publisher: zod_1.z.string().min(1), offer: zod_1.z.string().min(1), sku: zod_1.z.string().min(1) });
|
||||||
|
const renderBody = zod_1.z.object({
|
||||||
|
templateFile: zod_1.z.string().min(1),
|
||||||
|
selection: zod_1.z.object({
|
||||||
|
location: zod_1.z.string().min(1),
|
||||||
|
publisher: zod_1.z.string().min(1),
|
||||||
|
offer: zod_1.z.string().min(1),
|
||||||
|
sku: zod_1.z.string().min(1),
|
||||||
|
version: zod_1.z.string().min(1)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const makeApp = () => {
|
||||||
|
const app = (0, express_1.default)();
|
||||||
|
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
||||||
|
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
|
||||||
|
app.use((0, cors_1.default)());
|
||||||
|
app.use(express_1.default.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 azure_service_1.AzureImageService(subscriptionId);
|
||||||
|
const templates = new template_service_1.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, _req, res, _next) => {
|
||||||
|
if (err instanceof zod_1.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 = (0, node_path_1.join)(findAppNewRoot(), "dist/frontend");
|
||||||
|
if ((0, node_fs_1.existsSync)(frontendRoot)) {
|
||||||
|
app.use(express_1.default.static(frontendRoot));
|
||||||
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||||
|
res.sendFile((0, node_path_1.join)(frontendRoot, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { app, port };
|
||||||
|
};
|
||||||
|
exports.makeApp = makeApp;
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
36
app-new/dist/backend/template-service.js
vendored
Normal file
36
app-new/dist/backend/template-service.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.TemplateService = void 0;
|
||||||
|
const node_fs_1 = require("node:fs");
|
||||||
|
const node_path_1 = require("node:path");
|
||||||
|
const nunjucks_1 = __importDefault(require("nunjucks"));
|
||||||
|
const findAppNewRoot = () => {
|
||||||
|
const candidates = [(0, node_path_1.join)(__dirname, "../../.."), (0, node_path_1.join)(__dirname, "../..")];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(candidate, "templates.json"))) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unable to resolve app-new template root");
|
||||||
|
};
|
||||||
|
class TemplateService {
|
||||||
|
appNewRoot = findAppNewRoot();
|
||||||
|
env = nunjucks_1.default.configure((0, node_path_1.join)(this.appNewRoot, "templates"), {
|
||||||
|
autoescape: false,
|
||||||
|
noCache: true
|
||||||
|
});
|
||||||
|
templates = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(this.appNewRoot, "templates.json"), "utf8"));
|
||||||
|
getTemplates() {
|
||||||
|
return this.templates;
|
||||||
|
}
|
||||||
|
render(templateFile, selection) {
|
||||||
|
return this.env.render(templateFile, selection);
|
||||||
|
}
|
||||||
|
buildSkuExport(skus) {
|
||||||
|
return `[\n${skus.map((sku) => `\t\"${sku}\"`).join(",\n")}\n]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.TemplateService = TemplateService;
|
||||||
2
app-new/dist/backend/types.js
vendored
Normal file
2
app-new/dist/backend/types.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
21
app-new/dist/backend/version.js
vendored
Normal file
21
app-new/dist/backend/version.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.sortImageVersionsIfSemantic = void 0;
|
||||||
|
const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
||||||
|
const semverSortKey = (value) => value.split(".").map((part) => Number.parseInt(part, 10));
|
||||||
|
const sortImageVersionsIfSemantic = (versions) => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
exports.sortImageVersionsIfSemantic = sortImageVersionsIfSemantic;
|
||||||
119
app-new/dist/frontend/assets/index-DPvVOxFj.js
vendored
Normal file
119
app-new/dist/frontend/assets/index-DPvVOxFj.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
app-new/dist/frontend/index.html
vendored
Normal file
12
app-new/dist/frontend/index.html
vendored
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>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DPvVOxFj.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
app-new/frontend/package-lock.json
generated
16
app-new/frontend/package-lock.json
generated
@@ -4334,22 +4334,6 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
|
||||||
"extraneous": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -84,6 +86,32 @@ const App = () => {
|
|||||||
[selection, 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 () => {
|
const onRender = async () => {
|
||||||
setRenderError("");
|
setRenderError("");
|
||||||
try {
|
try {
|
||||||
@@ -99,12 +127,13 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loading = locationsQuery.isLoading || templatesQuery.isLoading;
|
const loading = locationsQuery.isLoading || templatesQuery.isLoading;
|
||||||
|
const locationsError = locationsQuery.error instanceof Error ? locationsQuery.error.message : "Failed to load locations";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: "100vh", py: 6 }}>
|
<Box sx={{ minHeight: "100vh", py: 4 }}>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Typography variant="h3" sx={{ fontWeight: 700 }}>
|
<Typography variant="h4" sx={{ fontWeight: 500 }}>
|
||||||
Azure Image Chooser
|
Azure Image Chooser
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="text.secondary">
|
<Typography color="text.secondary">
|
||||||
@@ -113,6 +142,8 @@ const App = () => {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
|
) : locationsQuery.isError ? (
|
||||||
|
<Alert severity="error">{locationsError}</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -136,57 +167,36 @@ const App = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<FormControl fullWidth disabled={!selection.location}>
|
<Autocomplete
|
||||||
<InputLabel id="publisher-label">Publisher</InputLabel>
|
options={publishers}
|
||||||
<Select
|
value={selection.publisher || null}
|
||||||
labelId="publisher-label"
|
disabled={!selection.location}
|
||||||
label="Publisher"
|
onChange={(_event, value) => setSelection((prev) => ({ ...prev, publisher: value ?? "" }))}
|
||||||
value={selection.publisher}
|
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
||||||
onChange={(event) => setSelection((prev) => ({ ...prev, publisher: event.target.value }))}
|
renderInput={(params) => <TextField {...params} label="Publisher" />}
|
||||||
>
|
/>
|
||||||
{(publishersQuery.data ?? []).map((publisher) => (
|
|
||||||
<MenuItem key={publisher} value={publisher}>
|
|
||||||
{publisher}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
<FormControl fullWidth disabled={!selection.publisher}>
|
<Autocomplete
|
||||||
<InputLabel id="offer-label">Offer</InputLabel>
|
options={offers}
|
||||||
<Select
|
value={selection.offer || null}
|
||||||
labelId="offer-label"
|
disabled={!selection.publisher}
|
||||||
label="Offer"
|
onChange={(_event, value) => setSelection((prev) => ({ ...prev, offer: value ?? "" }))}
|
||||||
value={selection.offer}
|
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
||||||
onChange={(event) => setSelection((prev) => ({ ...prev, offer: event.target.value }))}
|
renderInput={(params) => <TextField {...params} label="Offer" />}
|
||||||
>
|
/>
|
||||||
{(offersQuery.data ?? []).map((offer) => (
|
|
||||||
<MenuItem key={offer} value={offer}>
|
|
||||||
{offer}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
<FormControl fullWidth disabled={!selection.offer}>
|
<Autocomplete
|
||||||
<InputLabel id="sku-label">SKU</InputLabel>
|
options={skus}
|
||||||
<Select
|
value={selection.sku || null}
|
||||||
labelId="sku-label"
|
disabled={!selection.offer}
|
||||||
label="SKU"
|
onChange={(_event, value) => setSelection((prev) => ({ ...prev, sku: value ?? "" }))}
|
||||||
value={selection.sku}
|
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
|
||||||
onChange={(event) => setSelection((prev) => ({ ...prev, sku: event.target.value }))}
|
renderInput={(params) => <TextField {...params} label="SKU" />}
|
||||||
>
|
/>
|
||||||
{(skusQuery.data ?? []).map((sku) => (
|
|
||||||
<MenuItem key={sku} value={sku}>
|
|
||||||
{sku}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
@@ -198,7 +208,7 @@ const App = () => {
|
|||||||
value={selection.version}
|
value={selection.version}
|
||||||
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
|
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
|
||||||
>
|
>
|
||||||
{(versionsQuery.data ?? []).map((version) => (
|
{versions.map((version) => (
|
||||||
<MenuItem key={version} value={version}>
|
<MenuItem key={version} value={version}>
|
||||||
{version}
|
{version}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -226,7 +236,7 @@ const App = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
<Button fullWidth variant="contained" sx={{ height: "100%" }} onClick={onRender} disabled={!canRender}>
|
<Button fullWidth variant="contained" sx={{ height: "100%" }} color="primary" onClick={onRender} disabled={!canRender}>
|
||||||
Generate usage
|
Generate usage
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -241,7 +251,10 @@ const App = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6">Usage snippet</Typography>
|
<Typography variant="h6">Usage snippet</Typography>
|
||||||
<Box component="pre" sx={{ overflowX: "auto", p: 2, bgcolor: "#0f172a", color: "#e2e8f0", borderRadius: 2 }}>
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
{renderedUsage}
|
{renderedUsage}
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -252,7 +265,10 @@ const App = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6">Available SKUs</Typography>
|
<Typography variant="h6">Available SKUs</Typography>
|
||||||
<Box component="pre" sx={{ overflowX: "auto", p: 2, bgcolor: "#111827", color: "#d1fae5", borderRadius: 2 }}>
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
{skuExport}
|
{skuExport}
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import type { LocationOption, SelectionState, UsageTemplate } from "./types";
|
|||||||
const json = async <T>(path: string): Promise<T> => {
|
const json = async <T>(path: string): Promise<T> => {
|
||||||
const response = await fetch(path);
|
const response = await fetch(path);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed for ${path}`);
|
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;
|
return (await response.json()) as T;
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
|
import { CssBaseline } from "@mui/material";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,10 +32,17 @@ describe("App", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders application heading", async () => {
|
it("renders application heading", async () => {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
gcTime: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
const theme = createTheme();
|
const theme = createTheme();
|
||||||
|
|
||||||
render(
|
const view = render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<App />
|
<App />
|
||||||
@@ -44,5 +51,7 @@ describe("App", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("Azure Image Chooser")).toBeInTheDocument();
|
expect(await screen.findByText("Azure Image Chooser")).toBeInTheDocument();
|
||||||
|
view.unmount();
|
||||||
|
client.clear();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import react from "@vitejs/plugin-react";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
outDir: "../../dist/frontend",
|
outDir: "../dist/frontend",
|
||||||
emptyOutDir: true
|
emptyOutDir: true
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
22
app-new/templates.json
Normal file
22
app-new/templates.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
17
app-new/templates/arm_vm.jsonc
Normal file
17
app-new/templates/arm_vm.jsonc
Normal 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 }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app-new/templates/azurerm_hcl.tpl
Normal file
6
app-new/templates/azurerm_hcl.tpl
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
source_image_reference = {
|
||||||
|
publisher = "{{ publisher }}"
|
||||||
|
offer = "{{ offer }}"
|
||||||
|
sku = "{{ sku }}"
|
||||||
|
version = "{{ version }}"
|
||||||
|
}
|
||||||
14
app-new/templates/bicep_vm.tpl
Normal file
14
app-new/templates/bicep_vm.tpl
Normal 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-new/templates/shell.tpl
Normal file
1
app-new/templates/shell.tpl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
exec node dist/backend/server.js
|
exec node /app-new/dist/backend/server.js
|
||||||
|
|||||||
Reference in New Issue
Block a user