feat: implement VM SKU chooser tool with filtering and location persistence
This commit is contained in:
@@ -2,7 +2,7 @@ import { ComputeManagementClient } from "@azure/arm-compute";
|
|||||||
import { DefaultAzureCredential } from "@azure/identity";
|
import { DefaultAzureCredential } from "@azure/identity";
|
||||||
import { MemoryCache } from "./cache";
|
import { MemoryCache } from "./cache";
|
||||||
import { sortImageVersionsIfSemantic } from "./version";
|
import { sortImageVersionsIfSemantic } from "./version";
|
||||||
import type { LocationOption } from "./types";
|
import type { LocationOption, VmSkuOption } from "./types";
|
||||||
|
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@@ -116,6 +116,47 @@ export class AzureImageService {
|
|||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getVmSkus(location: string): Promise<VmSkuOption[]> {
|
||||||
|
const cacheKey = `vm-skus:${location}`;
|
||||||
|
const cached = this.cache.get<VmSkuOption[]>(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[] {
|
private extractNames(source: unknown): string[] {
|
||||||
const items = Array.isArray(source)
|
const items = Array.isArray(source)
|
||||||
? 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))
|
.map((item) => (typeof item === "object" && item !== null && "name" in item ? (item as { name?: string }).name : undefined))
|
||||||
.filter((value): value is string => Boolean(value));
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
app.get("/api/templates", (_req, res) => {
|
||||||
res.json(templates.getTemplates());
|
res.json(templates.getTemplates());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,3 +16,13 @@ export type ImageSelection = {
|
|||||||
sku: string;
|
sku: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VmSkuOption = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
family: string;
|
||||||
|
tier: string;
|
||||||
|
vcpus: number | null;
|
||||||
|
memoryGb: number | null;
|
||||||
|
maxDataDiskCount: number | null;
|
||||||
|
};
|
||||||
|
|||||||
98
app/frontend/package-lock.json
generated
98
app/frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.0.0",
|
"@mui/icons-material": "^9.0.0",
|
||||||
"@mui/material": "^9.0.0",
|
"@mui/material": "^9.0.0",
|
||||||
|
"@mui/x-data-grid": "^9.0.2",
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
@@ -3547,6 +3630,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -3909,6 +3998,15 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.8",
|
"version": "8.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.0.0",
|
"@mui/icons-material": "^9.0.0",
|
||||||
"@mui/material": "^9.0.0",
|
"@mui/material": "^9.0.0",
|
||||||
|
"@mui/x-data-grid": "^9.0.2",
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.99.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { LocationOption, SelectionState, UsageTemplate } from "./types";
|
import type { LocationOption, SelectionState, UsageTemplate, VmSkuOption } 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);
|
||||||
@@ -31,6 +31,7 @@ export const api = {
|
|||||||
json<string[]>(
|
json<string[]>(
|
||||||
`/api/versions?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}&sku=${encodeURIComponent(sku)}`
|
`/api/versions?location=${encodeURIComponent(location)}&publisher=${encodeURIComponent(publisher)}&offer=${encodeURIComponent(offer)}&sku=${encodeURIComponent(sku)}`
|
||||||
),
|
),
|
||||||
|
vmSkus: (location: string) => json<VmSkuOption[]>(`/api/vm-skus?location=${encodeURIComponent(location)}`),
|
||||||
templates: () => json<UsageTemplate[]>("/api/templates"),
|
templates: () => json<UsageTemplate[]>("/api/templates"),
|
||||||
render: async (templateFile: string, selection: SelectionState): Promise<string> => {
|
render: async (templateFile: string, selection: SelectionState): Promise<string> => {
|
||||||
const response = await fetch("/api/render", {
|
const response = await fetch("/api/render", {
|
||||||
|
|||||||
1
app/frontend/src/help/vm-sku-chooser-help.md
Normal file
1
app/frontend/src/help/vm-sku-chooser-help.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Use this tool to list VM SKUs available in a selected Azure region.
|
||||||
33
app/frontend/src/location-helper.ts
Normal file
33
app/frontend/src/location-helper.ts
Normal file
@@ -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`;
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { readSharedLocation, writeSharedLocation } from "../location-helper";
|
||||||
import type { SelectionState } from "../types";
|
import type { SelectionState } from "../types";
|
||||||
|
|
||||||
const EMPTY_SELECTION: SelectionState = {
|
const EMPTY_SELECTION: SelectionState = {
|
||||||
@@ -29,7 +30,10 @@ const EMPTY_SELECTION: SelectionState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ImageChooserTool = () => {
|
const ImageChooserTool = () => {
|
||||||
const [selection, setSelection] = useState<SelectionState>(EMPTY_SELECTION);
|
const [selection, setSelection] = useState<SelectionState>(() => ({
|
||||||
|
...EMPTY_SELECTION,
|
||||||
|
location: readSharedLocation()
|
||||||
|
}));
|
||||||
const [templateFile, setTemplateFile] = useState("");
|
const [templateFile, setTemplateFile] = useState("");
|
||||||
const [renderedUsage, setRenderedUsage] = useState("");
|
const [renderedUsage, setRenderedUsage] = useState("");
|
||||||
const [skuExport, setSkuExport] = useState("");
|
const [skuExport, setSkuExport] = useState("");
|
||||||
@@ -161,7 +165,11 @@ const ImageChooserTool = () => {
|
|||||||
labelId="location-label"
|
labelId="location-label"
|
||||||
label="Location"
|
label="Location"
|
||||||
value={selection.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) => (
|
{(locationsQuery.data ?? []).map((location) => (
|
||||||
<MenuItem key={location.name} value={location.name}>
|
<MenuItem key={location.name} value={location.name}>
|
||||||
|
|||||||
361
app/frontend/src/tools/VmSkuChooserTool.tsx
Normal file
361
app/frontend/src/tools/VmSkuChooserTool.tsx
Normal file
@@ -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<string>();
|
||||||
|
|
||||||
|
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<VmSkuRow[]>(
|
||||||
|
() =>
|
||||||
|
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<GridColDef<VmSkuRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 500 }}>
|
||||||
|
VM SKU Chooser
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Browse available VM SKUs in a region using Azure API data.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{appNotConfigured ? (
|
||||||
|
<Alert severity="warning">
|
||||||
|
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
lg: "repeat(2, minmax(0, 1fr))"
|
||||||
|
},
|
||||||
|
gap: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="vm-sku-location-label">Location</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="vm-sku-location-label"
|
||||||
|
label="Location"
|
||||||
|
value={location}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextLocation = event.target.value;
|
||||||
|
setLocation(nextLocation);
|
||||||
|
writeSharedLocation(nextLocation);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(locationsQuery.data ?? []).map((option) => (
|
||||||
|
<MenuItem key={option.name} value={option.name}>
|
||||||
|
{option.displayName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Filter by SKU name"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(event) => setNameFilter(event.target.value)}
|
||||||
|
disabled={!location}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
sm: "repeat(2, minmax(0, 1fr))",
|
||||||
|
lg: "repeat(5, minmax(0, 1fr))"
|
||||||
|
},
|
||||||
|
gap: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl fullWidth disabled={!location}>
|
||||||
|
<InputLabel id="vm-sku-family-filter-label">VM Family</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="vm-sku-family-filter-label"
|
||||||
|
label="VM Family"
|
||||||
|
value={familyFilter}
|
||||||
|
onChange={(event) => setFamilyFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">All families</MenuItem>
|
||||||
|
{familyOptions.map((family) => (
|
||||||
|
<MenuItem key={family} value={family}>
|
||||||
|
{family}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Min vCPUs"
|
||||||
|
value={minVcpusFilter}
|
||||||
|
onChange={(event) => setMinVcpusFilter(event.target.value)}
|
||||||
|
disabled={!location}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Max vCPUs"
|
||||||
|
value={maxVcpusFilter}
|
||||||
|
onChange={(event) => setMaxVcpusFilter(event.target.value)}
|
||||||
|
disabled={!location}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Min Memory (GB)"
|
||||||
|
value={minMemoryFilter}
|
||||||
|
onChange={(event) => setMinMemoryFilter(event.target.value)}
|
||||||
|
disabled={!location}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Max Memory (GB)"
|
||||||
|
value={maxMemoryFilter}
|
||||||
|
onChange={(event) => setMaxMemoryFilter(event.target.value)}
|
||||||
|
disabled={!location}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{locationsQuery.isLoading ? <CircularProgress /> : null}
|
||||||
|
{locationsQuery.error instanceof Error ? <Alert severity={appNotConfigured ? "warning" : "error"}>{locationsQuery.error.message}</Alert> : null}
|
||||||
|
|
||||||
|
{vmSkusQuery.isLoading ? <CircularProgress /> : null}
|
||||||
|
{vmSkusQuery.error instanceof Error ? <Alert severity="error">{vmSkusQuery.error.message}</Alert> : null}
|
||||||
|
|
||||||
|
{location && !vmSkusQuery.isLoading && !vmSkusQuery.isError ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Typography variant="h6">Available SKUs ({filteredSkus.length})</Typography>
|
||||||
|
<Box sx={{ height: 520 }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
sx={{
|
||||||
|
[`& .${HEADER_CLASS_NAME}`]: {
|
||||||
|
bgcolor: "grey.200"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[25, 50, 100]}
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: { pageSize: 25, page: 0 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{rows.length === 0 ? <Typography color="text.secondary">No SKUs found for this filter.</Typography> : null}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VmSkuChooserTool;
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import ImageChooserTool from "./ImageChooserTool";
|
import ImageChooserTool from "./ImageChooserTool";
|
||||||
import EmptyTool from "./EmptyTool";
|
import EmptyTool from "./EmptyTool";
|
||||||
|
import VmSkuChooserTool from "./VmSkuChooserTool";
|
||||||
import imageChooserHelp from "../help/image-chooser-help.md?raw";
|
import imageChooserHelp from "../help/image-chooser-help.md?raw";
|
||||||
|
import vmSkuChooserHelp from "../help/vm-sku-chooser-help.md?raw";
|
||||||
import type { ToolDefinition } from "./types";
|
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[] = [
|
export const TOOLS: ToolDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "image-chooser",
|
id: "image-chooser",
|
||||||
@@ -10,9 +15,19 @@ export const TOOLS: ToolDefinition[] = [
|
|||||||
helpContent: imageChooserHelp,
|
helpContent: imageChooserHelp,
|
||||||
render: () => <ImageChooserTool />
|
render: () => <ImageChooserTool />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "vm-sku-chooser",
|
||||||
|
name: "VM SKU Chooser",
|
||||||
|
helpContent: vmSkuChooserHelp,
|
||||||
|
render: () => <VmSkuChooserTool />
|
||||||
|
},
|
||||||
|
...(!isProductionMode
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
id: "empty-tool",
|
id: "empty-tool",
|
||||||
name: "Empty Tool",
|
name: "Empty Tool",
|
||||||
render: () => <EmptyTool />
|
render: () => <EmptyTool />
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,3 +16,13 @@ export type SelectionState = {
|
|||||||
sku: string;
|
sku: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VmSkuOption = {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
family: string;
|
||||||
|
tier: string;
|
||||||
|
vcpus: number | null;
|
||||||
|
memoryGb: number | null;
|
||||||
|
maxDataDiskCount: number | null;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user