diff --git a/app/backend/src/azure-service.ts b/app/backend/src/azure-service.ts index e364648..50e1ddc 100644 --- a/app/backend/src/azure-service.ts +++ b/app/backend/src/azure-service.ts @@ -2,7 +2,7 @@ import { ComputeManagementClient } from "@azure/arm-compute"; import { DefaultAzureCredential } from "@azure/identity"; import { MemoryCache } from "./cache"; import { sortImageVersionsIfSemantic } from "./version"; -import type { LocationOption } from "./types"; +import type { LocationOption, VmSkuOption } from "./types"; const CACHE_TTL_MS = 5 * 60 * 1000; @@ -116,6 +116,47 @@ export class AzureImageService { return sorted; } + public async getVmSkus(location: string): Promise { + const cacheKey = `vm-skus:${location}`; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached; + } + + const targetLocation = location.toLowerCase(); + const vmSkus: VmSkuOption[] = []; + + for await (const sku of this.computeClient.resourceSkus.list()) { + if ((sku.resourceType ?? "").toLowerCase() !== "virtualmachines") { + continue; + } + + const skuLocations = (sku.locations ?? []).map((item) => item.toLowerCase()); + const matchesLocation = skuLocations.includes(targetLocation); + if (!matchesLocation) { + continue; + } + + const capabilities = new Map( + (sku.capabilities ?? []).map((item) => [item.name?.toLowerCase() ?? "", item.value ?? ""]) + ); + + vmSkus.push({ + name: sku.name ?? "", + size: sku.size ?? "", + family: sku.family ?? "", + tier: sku.tier ?? "", + vcpus: this.toNumber(capabilities.get("vcpus")), + memoryGb: this.toNumber(capabilities.get("memorygb")), + maxDataDiskCount: this.toNumber(capabilities.get("maxdatadiskcount")) + }); + } + + vmSkus.sort((a, b) => a.name.localeCompare(b.name)); + this.cache.set(cacheKey, vmSkus, CACHE_TTL_MS); + return vmSkus; + } + private extractNames(source: unknown): string[] { const items = Array.isArray(source) ? source @@ -127,4 +168,14 @@ export class AzureImageService { .map((item) => (typeof item === "object" && item !== null && "name" in item ? (item as { name?: string }).name : undefined)) .filter((value): value is string => Boolean(value)); } + + private toNumber(value: string | undefined): number | null { + if (!value) { + return null; + } + + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + } + } diff --git a/app/backend/src/server.ts b/app/backend/src/server.ts index 8f7ad2d..4dfeed1 100644 --- a/app/backend/src/server.ts +++ b/app/backend/src/server.ts @@ -110,6 +110,15 @@ const makeApp = () => { } }); + app.get("/api/vm-skus", async (req, res, next) => { + try { + const { location } = queryLocation.parse(req.query); + res.json(await requireAzure().getVmSkus(location)); + } catch (error) { + next(error); + } + }); + app.get("/api/templates", (_req, res) => { res.json(templates.getTemplates()); }); diff --git a/app/backend/src/types.ts b/app/backend/src/types.ts index 0950e9c..09f2924 100644 --- a/app/backend/src/types.ts +++ b/app/backend/src/types.ts @@ -16,3 +16,13 @@ export type ImageSelection = { sku: string; version: string; }; + +export type VmSkuOption = { + name: string; + size: string; + family: string; + tier: string; + vcpus: number | null; + memoryGb: number | null; + maxDataDiskCount: number | null; +}; diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 3de1c6d..6bc0b6e 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -13,6 +13,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", + "@mui/x-data-grid": "^9.0.2", "@tanstack/react-query": "^5.99.2", "react": "^19.2.5", "react-dom": "^19.2.5" @@ -1265,6 +1266,88 @@ } } }, + "node_modules/@mui/x-data-grid": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-9.0.2.tgz", + "integrity": "sha512-9hkBS73x3G5MniOpkCh54iH5iwBr55obchF5IS1eybURZEgPxSXFizNMrDbyM2EGaYG9DQ87MvC5IoV7g0F2Vw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "9.0.0", + "@mui/x-internals": "^9.0.0", + "@mui/x-virtualizer": "9.0.0-alpha.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", + "integrity": "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "9.0.0", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-virtualizer": { + "version": "9.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-9.0.0-alpha.0.tgz", + "integrity": "sha512-K52TKCuWlkMEWOeB2nPfhIAHaWsYEb9h1ME9Wb+gmw4FloMA03VvKsrqvn8o6l8hYUi4/5F8NfYOIfPwqW3EhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "9.0.0", + "@mui/x-internals": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -3547,6 +3630,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3909,6 +3998,15 @@ "node": ">=14.17" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index 69702fb..209a74c 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", + "@mui/x-data-grid": "^9.0.2", "@tanstack/react-query": "^5.99.2", "react": "^19.2.5", "react-dom": "^19.2.5" diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index 2cc7a31..8891a50 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { LocationOption, SelectionState, UsageTemplate } from "./types"; +import type { LocationOption, SelectionState, UsageTemplate, VmSkuOption } from "./types"; const json = async (path: string): Promise => { const response = await fetch(path); @@ -31,6 +31,7 @@ export const api = { json( `/api/versions?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}&sku=${encodeURIComponent(sku)}` ), + vmSkus: (location: string) => json(`/api/vm-skus?location=${encodeURIComponent(location)}`), templates: () => json("/api/templates"), render: async (templateFile: string, selection: SelectionState): Promise => { const response = await fetch("/api/render", { diff --git a/app/frontend/src/help/vm-sku-chooser-help.md b/app/frontend/src/help/vm-sku-chooser-help.md new file mode 100644 index 0000000..905c122 --- /dev/null +++ b/app/frontend/src/help/vm-sku-chooser-help.md @@ -0,0 +1 @@ +Use this tool to list VM SKUs available in a selected Azure region. \ No newline at end of file diff --git a/app/frontend/src/location-helper.ts b/app/frontend/src/location-helper.ts new file mode 100644 index 0000000..c85a8f8 --- /dev/null +++ b/app/frontend/src/location-helper.ts @@ -0,0 +1,33 @@ +export const SHARED_LOCATION_COOKIE_NAME = "location"; + +export const readSharedLocation = (): string => { + if (typeof document === "undefined") { + return ""; + } + + const cookies = document.cookie ? document.cookie.split("; ") : []; + for (const entry of cookies) { + const separatorIndex = entry.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const key = entry.slice(0, separatorIndex); + if (key !== SHARED_LOCATION_COOKIE_NAME) { + continue; + } + + return decodeURIComponent(entry.slice(separatorIndex + 1)); + } + + return ""; +}; + +export const writeSharedLocation = (location: string): void => { + if (typeof document === "undefined") { + return; + } + + // Keep selected location for 30 days across tools. + document.cookie = `${SHARED_LOCATION_COOKIE_NAME}=${encodeURIComponent(location)}; Max-Age=${60 * 60 * 24 * 30}; Path=/; SameSite=Lax`; +}; diff --git a/app/frontend/src/tools/ImageChooserTool.tsx b/app/frontend/src/tools/ImageChooserTool.tsx index 800c6df..dde9b65 100644 --- a/app/frontend/src/tools/ImageChooserTool.tsx +++ b/app/frontend/src/tools/ImageChooserTool.tsx @@ -18,6 +18,7 @@ import { } from "@mui/material"; import { useQuery } from "@tanstack/react-query"; import { api } from "../api"; +import { readSharedLocation, writeSharedLocation } from "../location-helper"; import type { SelectionState } from "../types"; const EMPTY_SELECTION: SelectionState = { @@ -29,7 +30,10 @@ const EMPTY_SELECTION: SelectionState = { }; const ImageChooserTool = () => { - const [selection, setSelection] = useState(EMPTY_SELECTION); + const [selection, setSelection] = useState(() => ({ + ...EMPTY_SELECTION, + location: readSharedLocation() + })); const [templateFile, setTemplateFile] = useState(""); const [renderedUsage, setRenderedUsage] = useState(""); const [skuExport, setSkuExport] = useState(""); @@ -161,7 +165,11 @@ const ImageChooserTool = () => { labelId="location-label" label="Location" value={selection.location} - onChange={(event) => setSelection((prev) => ({ ...prev, location: event.target.value }))} + onChange={(event) => { + const nextLocation = event.target.value; + setSelection((prev) => ({ ...prev, location: nextLocation })); + writeSharedLocation(nextLocation); + }} > {(locationsQuery.data ?? []).map((location) => ( diff --git a/app/frontend/src/tools/VmSkuChooserTool.tsx b/app/frontend/src/tools/VmSkuChooserTool.tsx new file mode 100644 index 0000000..2a5b8d1 --- /dev/null +++ b/app/frontend/src/tools/VmSkuChooserTool.tsx @@ -0,0 +1,361 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Box, + Card, + CardContent, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography +} from "@mui/material"; +import { DataGrid, type GridColDef } from "@mui/x-data-grid"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { readSharedLocation, writeSharedLocation } from "../location-helper"; +import type { VmSkuOption } from "../types"; + +const HEADER_CLASS_NAME = "vm-sku-grid-header"; + +type VmSkuRow = { + id: string; + name: string; + family: string; + vcpus: string; + memoryGb: string; + maxDataDiskCount: string; +}; + +const formatValue = (value: number | null): string => { + if (value === null) { + return "-"; + } + + return Number.isInteger(value) ? String(value) : value.toFixed(1); +}; + +const parseNumericFilter = (value: string): number | null => { + if (!value.trim()) { + return null; + } + + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) { + return null; + } + + return parsed; +}; + +const extractFamilyCode = (sku: VmSkuOption): string => { + const skuName = sku.name ?? ""; + const skuFamily = sku.family ?? ""; + + const byName = skuName.match(/^Standard_([A-Za-z])/i); + if (byName?.[1]) { + return byName[1].toUpperCase(); + } + + const byFamily = skuFamily.match(/([A-Za-z])Family/i); + if (byFamily?.[1]) { + return byFamily[1].toUpperCase(); + } + + return ""; +}; + +const VmSkuChooserTool = () => { + const [location, setLocation] = useState(() => readSharedLocation()); + const [nameFilter, setNameFilter] = useState(""); + const [familyFilter, setFamilyFilter] = useState(""); + const [minVcpusFilter, setMinVcpusFilter] = useState(""); + const [maxVcpusFilter, setMaxVcpusFilter] = useState(""); + const [minMemoryFilter, setMinMemoryFilter] = useState(""); + const [maxMemoryFilter, setMaxMemoryFilter] = useState(""); + + const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health }); + const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations }); + const vmSkusQuery = useQuery({ + queryKey: ["vm-skus", location], + queryFn: () => api.vmSkus(location), + enabled: Boolean(location) + }); + + const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : ""; + const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID"); + + const familyOptions = useMemo(() => { + const source = vmSkusQuery.data ?? []; + const families = new Set(); + + for (const sku of source) { + const code = extractFamilyCode(sku); + if (code) { + families.add(code); + } + } + + return Array.from(families).sort((a, b) => a.localeCompare(b)); + }, [vmSkusQuery.data]); + + const filteredSkus = useMemo(() => { + const source = vmSkusQuery.data ?? []; + const nameQuery = nameFilter.trim().toLowerCase(); + const minVcpus = parseNumericFilter(minVcpusFilter); + const maxVcpus = parseNumericFilter(maxVcpusFilter); + const minMemory = parseNumericFilter(minMemoryFilter); + const maxMemory = parseNumericFilter(maxMemoryFilter); + + return source.filter((item) => { + const itemName = item.name ?? ""; + if (nameQuery && !itemName.toLowerCase().includes(nameQuery)) { + return false; + } + + const familyCode = extractFamilyCode(item); + if (familyFilter && familyCode !== familyFilter) { + return false; + } + + if (minVcpus !== null && (item.vcpus === null || item.vcpus < minVcpus)) { + return false; + } + + if (maxVcpus !== null && (item.vcpus === null || item.vcpus > maxVcpus)) { + return false; + } + + if (minMemory !== null && (item.memoryGb === null || item.memoryGb < minMemory)) { + return false; + } + + if (maxMemory !== null && (item.memoryGb === null || item.memoryGb > maxMemory)) { + return false; + } + + return true; + }); + }, [vmSkusQuery.data, nameFilter, familyFilter, minVcpusFilter, maxVcpusFilter, minMemoryFilter, maxMemoryFilter]); + + const rows = useMemo( + () => + filteredSkus.map((sku) => ({ + id: sku.name || `${sku.size || "sku"}-${sku.family || "unknown"}`, + name: sku.name || "-", + family: sku.family || sku.size || sku.tier || "-", + vcpus: formatValue(sku.vcpus), + memoryGb: formatValue(sku.memoryGb), + maxDataDiskCount: formatValue(sku.maxDataDiskCount) + })), + [filteredSkus] + ); + + const columns = useMemo[]>( + () => [ + { + field: "name", + headerName: "SKU", + minWidth: 220, + flex: 1.4, + headerClassName: HEADER_CLASS_NAME + }, + { + field: "family", + headerName: "Family", + minWidth: 140, + flex: 0.9, + headerClassName: HEADER_CLASS_NAME + }, + { + field: "vcpus", + headerName: "vCPUs", + minWidth: 110, + align: "right", + headerAlign: "right", + headerClassName: HEADER_CLASS_NAME + }, + { + field: "memoryGb", + headerName: "Memory (GB)", + minWidth: 130, + align: "right", + headerAlign: "right", + headerClassName: HEADER_CLASS_NAME + }, + { + field: "maxDataDiskCount", + headerName: "Max Data Disks", + minWidth: 150, + align: "right", + headerAlign: "right", + headerClassName: HEADER_CLASS_NAME + } + ], + [] + ); + + return ( + + + VM SKU Chooser + + + Browse available VM SKUs in a region using Azure API data. + + + {appNotConfigured ? ( + + App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app. + + ) : null} + + + + + + + Location + + + + setNameFilter(event.target.value)} + disabled={!location} + fullWidth + /> + + + + + VM Family + + + + setMinVcpusFilter(event.target.value)} + disabled={!location} + /> + + setMaxVcpusFilter(event.target.value)} + disabled={!location} + /> + + setMinMemoryFilter(event.target.value)} + disabled={!location} + /> + + setMaxMemoryFilter(event.target.value)} + disabled={!location} + /> + + + + + + {locationsQuery.isLoading ? : null} + {locationsQuery.error instanceof Error ? {locationsQuery.error.message} : null} + + {vmSkusQuery.isLoading ? : null} + {vmSkusQuery.error instanceof Error ? {vmSkusQuery.error.message} : null} + + {location && !vmSkusQuery.isLoading && !vmSkusQuery.isError ? ( + + + + Available SKUs ({filteredSkus.length}) + + + + + {rows.length === 0 ? No SKUs found for this filter. : null} + + + + ) : null} + + ); +}; + +export default VmSkuChooserTool; diff --git a/app/frontend/src/tools/toolRegistry.tsx b/app/frontend/src/tools/toolRegistry.tsx index 333e42e..b0f8598 100644 --- a/app/frontend/src/tools/toolRegistry.tsx +++ b/app/frontend/src/tools/toolRegistry.tsx @@ -1,8 +1,13 @@ import ImageChooserTool from "./ImageChooserTool"; import EmptyTool from "./EmptyTool"; +import VmSkuChooserTool from "./VmSkuChooserTool"; import imageChooserHelp from "../help/image-chooser-help.md?raw"; +import vmSkuChooserHelp from "../help/vm-sku-chooser-help.md?raw"; import type { ToolDefinition } from "./types"; +const isProductionMode = + import.meta.env.PROD || import.meta.env.MODE === "production" || import.meta.env.VITE_NODE === "production"; + export const TOOLS: ToolDefinition[] = [ { id: "image-chooser", @@ -11,8 +16,18 @@ export const TOOLS: ToolDefinition[] = [ render: () => }, { - id: "empty-tool", - name: "Empty Tool", - render: () => - } + id: "vm-sku-chooser", + name: "VM SKU Chooser", + helpContent: vmSkuChooserHelp, + render: () => + }, + ...(!isProductionMode + ? [ + { + id: "empty-tool", + name: "Empty Tool", + render: () => + } + ] + : []) ]; diff --git a/app/frontend/src/types.ts b/app/frontend/src/types.ts index 8c575f7..fc5bf5d 100644 --- a/app/frontend/src/types.ts +++ b/app/frontend/src/types.ts @@ -16,3 +16,13 @@ export type SelectionState = { sku: string; version: string; }; + +export type VmSkuOption = { + name: string; + size: string; + family: string; + tier: string; + vcpus: number | null; + memoryGb: number | null; + maxDataDiskCount: number | null; +};