8 Commits

19 changed files with 1080 additions and 266 deletions

2
.gitignore vendored
View File

@@ -21,3 +21,5 @@ azure.env
# Node/React rewrite outputs # Node/React rewrite outputs
**/node_modules **/node_modules
**/dist **/dist
AGENTS.md

View File

@@ -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,48 @@ 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 filter = `location eq '${location}'`;
const vmSkus: VmSkuOption[] = [];
for await (const sku of this.computeClient.resourceSkus.list({ filter })) {
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 +169,13 @@ 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 {
if (!value) {
return 0;
}
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
} }

View File

@@ -37,6 +37,7 @@ const renderBody = z.object({
const makeApp = () => { const makeApp = () => {
const app = express(); const app = express();
const port = Number.parseInt(process.env.PORT ?? "3000", 10); const port = Number.parseInt(process.env.PORT ?? "3000", 10);
const host = process.env.HOST ?? "0.0.0.0";
const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID; const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
app.use(cors()); app.use(cors());
@@ -109,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());
}); });
@@ -151,14 +161,14 @@ const makeApp = () => {
}); });
} }
return { app, port }; return { app, port, host };
}; };
if (require.main === module) { if (require.main === module) {
const { app, port } = makeApp(); const { app, port, host } = makeApp();
const server = app.listen(port, () => { const server = app.listen(port, host, () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`azure-image-chooser listening on ${port}`); console.log(`azure-image-chooser listening on ${host}:${port}`);
}); });
let shuttingDown = false; let shuttingDown = false;

View File

@@ -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;
memoryGb: number;
maxDataDiskCount: number;
};

18
app/build.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
REGISTRY="registry.koszewscy.waw.pl"
IMAGE_NAME="azure-image-chooser-node"
if command -v container >/dev/null; then
container build \
-t $REGISTRY/$IMAGE_NAME:latest \
--arch arm64 \
--arch amd64 \
. && \
container image push \
--arch amd64 \
$REGISTRY/$IMAGE_NAME:latest
else
echo "No compatible container builder found." >&2
exit 1
fi

View File

@@ -1,16 +1,19 @@
{ {
"name": "azure-image-chooser-frontend", "name": "azure-image-chooser-frontend",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "azure-image-chooser-frontend", "name": "azure-image-chooser-frontend",
"version": "0.1.0", "version": "1.0.0",
"license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@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"
@@ -1040,6 +1043,32 @@
"url": "https://opencollective.com/mui-org" "url": "https://opencollective.com/mui-org"
} }
}, },
"node_modules/@mui/icons-material": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.0.tgz",
"integrity": "sha512-oDwyvI6LgjWRC9MBcSGvLkPud9S9ELgSBQFYxa1rYcZn6Br55dn22SyvsPDMsn0G8OndFk53iMT45W5mNqrogw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^9.0.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": { "node_modules/@mui/material": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz",
@@ -1237,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",
@@ -3519,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",
@@ -3881,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",

View File

@@ -14,7 +14,9 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@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"

View File

@@ -1,290 +1,194 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { MouseEvent } from "react";
import MenuIcon from "@mui/icons-material/Menu";
import { import {
Alert, AppBar,
Autocomplete,
Box, Box,
Button, Button,
Card, Drawer,
CardContent, IconButton,
CircularProgress, List,
Container, ListItem,
FormControl, ListItemButton,
Grid, ListItemText,
InputLabel, Menu,
MenuItem, MenuItem,
Select, Toolbar,
Stack,
TextField,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { useQuery } from "@tanstack/react-query"; import { TOOLS } from "./tools/toolRegistry";
import { api } from "./api";
import type { SelectionState } from "./types";
const EMPTY_SELECTION: SelectionState = {
location: "",
publisher: "",
offer: "",
sku: "",
version: ""
};
const App = () => { const App = () => {
const [selection, setSelection] = useState<SelectionState>(EMPTY_SELECTION); const [activeToolId, setActiveToolId] = useState<string>(TOOLS[0]?.id ?? "");
const [templateFile, setTemplateFile] = useState(""); const [toolsAnchorEl, setToolsAnchorEl] = useState<null | HTMLElement>(null);
const [renderedUsage, setRenderedUsage] = useState(""); const [helpOpen, setHelpOpen] = useState(false);
const [skuExport, setSkuExport] = useState("");
const [renderError, setRenderError] = useState("");
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health }); const activeTool = useMemo(
const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations }); () => TOOLS.find((tool) => tool.id === activeToolId) ?? TOOLS[0],
const publishersQuery = useQuery({ [activeToolId]
queryKey: ["publishers", selection.location],
queryFn: () => api.publishers(selection.location),
enabled: Boolean(selection.location)
});
const offersQuery = useQuery({
queryKey: ["offers", selection.location, selection.publisher],
queryFn: () => api.offers(selection.location, selection.publisher),
enabled: Boolean(selection.location && selection.publisher)
});
const skusQuery = useQuery({
queryKey: ["skus", selection.location, selection.publisher, selection.offer],
queryFn: () => api.skus(selection.location, selection.publisher, selection.offer),
enabled: Boolean(selection.location && selection.publisher && selection.offer)
});
const versionsQuery = useQuery({
queryKey: ["versions", selection.location, selection.publisher, selection.offer, selection.sku],
queryFn: () => api.versions(selection.location, selection.publisher, selection.offer, selection.sku),
enabled: Boolean(selection.location && selection.publisher && selection.offer && selection.sku)
});
const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: api.templates });
useEffect(() => {
setSelection((previous) => ({ ...previous, publisher: "", offer: "", sku: "", version: "" }));
setRenderedUsage("");
setSkuExport("");
}, [selection.location]);
useEffect(() => {
setSelection((previous) => ({ ...previous, offer: "", sku: "", version: "" }));
setRenderedUsage("");
setSkuExport("");
}, [selection.publisher]);
useEffect(() => {
setSelection((previous) => ({ ...previous, sku: "", version: "" }));
setRenderedUsage("");
}, [selection.offer]);
useEffect(() => {
setSelection((previous) => ({ ...previous, version: "" }));
setRenderedUsage("");
}, [selection.sku]);
const canRender = useMemo(
() => Boolean(selection.location && selection.publisher && selection.offer && selection.sku && selection.version && templateFile),
[selection, templateFile]
); );
const publishers = useMemo( const toolsMenuOpen = Boolean(toolsAnchorEl);
() => [...(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 openToolsMenu = (event: MouseEvent<HTMLButtonElement>) => {
const query = inputValue.trim().toLowerCase(); setToolsAnchorEl(event.currentTarget);
if (!query) {
return options;
}
return options.filter((option) => option.toLowerCase().includes(query));
}; };
const onRender = async () => { const closeToolsMenu = () => {
setRenderError(""); setToolsAnchorEl(null);
try {
const [usage, skuBlock] = await Promise.all([
api.render(templateFile, selection),
api.skuExport(selection.location, selection.publisher, selection.offer)
]);
setRenderedUsage(usage);
setSkuExport(skuBlock);
} catch (error) {
setRenderError(error instanceof Error ? error.message : "Unexpected render error");
}
}; };
const loading = locationsQuery.isLoading || templatesQuery.isLoading; const selectTool = (toolId: string) => {
const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : ""; setActiveToolId(toolId);
const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID"); closeToolsMenu();
const locationsError = locationsQuery.error instanceof Error ? locationsQuery.error.message : "Failed to load locations"; };
return ( return (
<Box sx={{ minHeight: "100vh", py: 4 }}> <Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<Container maxWidth="lg"> <AppBar
<Stack spacing={3}> position="sticky"
<Typography variant="h4" sx={{ fontWeight: 500 }}> color="default"
Azure Image Chooser elevation={0}
</Typography> sx={{ borderBottom: 1, borderColor: "divider", display: { xs: "block", md: "none" } }}
<Typography color="text.secondary"> >
Select a marketplace image and generate reusable snippets. <Toolbar sx={{ gap: 1 }}>
<IconButton
color="inherit"
onClick={openToolsMenu}
aria-label="Open tools menu"
aria-controls={toolsMenuOpen ? "tools-menu" : undefined}
aria-haspopup="true"
aria-expanded={toolsMenuOpen ? "true" : undefined}
edge="start"
>
<MenuIcon />
</IconButton>
<Typography sx={{ flexGrow: 1, fontWeight: 500 }} noWrap>
{activeTool?.name ?? "Tool"}
</Typography> </Typography>
{appNotConfigured ? ( <Button variant="text" color="inherit" onClick={() => setHelpOpen(true)}>
<Alert severity="warning"> Help
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
</Alert>
) : null}
{loading ? (
<CircularProgress />
) : locationsQuery.isError ? (
<Alert severity={appNotConfigured ? "warning" : "error"}>{locationsError}</Alert>
) : (
<Card>
<CardContent>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel id="location-label">Location</InputLabel>
<Select
labelId="location-label"
label="Location"
value={selection.location}
onChange={(event) => setSelection((prev) => ({ ...prev, location: event.target.value }))}
>
{(locationsQuery.data ?? []).map((location) => (
<MenuItem key={location.name} value={location.name}>
{location.displayName}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Autocomplete
options={publishers}
value={selection.publisher || null}
disabled={!selection.location}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, publisher: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="Publisher" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Autocomplete
options={offers}
value={selection.offer || null}
disabled={!selection.publisher}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, offer: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="Offer" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Autocomplete
options={skus}
value={selection.sku || null}
disabled={!selection.offer}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, sku: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="SKU" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!selection.sku}>
<InputLabel id="version-label">Version</InputLabel>
<Select
labelId="version-label"
label="Version"
value={selection.version}
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
>
{versions.map((version) => (
<MenuItem key={version} value={version}>
{version}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<FormControl fullWidth>
<InputLabel id="template-label">Usage scenario</InputLabel>
<Select
labelId="template-label"
label="Usage scenario"
value={templateFile}
onChange={(event) => setTemplateFile(event.target.value)}
>
{(templatesQuery.data ?? []).map((template) => (
<MenuItem key={template.file} value={template.file}>
{template.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Button fullWidth variant="contained" sx={{ height: "100%" }} color="primary" onClick={onRender} disabled={!canRender}>
Generate usage
</Button> </Button>
</Grid> </Toolbar>
</Grid> </AppBar>
</CardContent>
</Card> <Menu id="tools-menu" anchorEl={toolsAnchorEl} open={toolsMenuOpen} onClose={closeToolsMenu}>
{TOOLS.map((tool) => (
<MenuItem key={tool.id} selected={tool.id === activeTool?.id} onClick={() => selectTool(tool.id)}>
{tool.name}
</MenuItem>
))}
</Menu>
<Drawer anchor="right" open={helpOpen} onClose={() => setHelpOpen(false)}>
<Box sx={{ width: { xs: "85vw", sm: 360 }, p: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Help
</Typography>
{activeTool?.helpContent ? (
<Box sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{activeTool.helpContent}</Box>
) : (
<Typography color="text.secondary">No help available for this tool.</Typography>
)} )}
<List sx={{ mt: 2 }}>
{renderError ? <Alert severity="error">{renderError}</Alert> : null} <ListItemButton onClick={() => setHelpOpen(false)}>
<ListItemText primary="Close" />
{renderedUsage ? ( </ListItemButton>
<Card> </List>
<CardContent>
<Typography variant="h6">Usage snippet</Typography>
<Box
component="pre"
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
>
{renderedUsage}
</Box> </Box>
</CardContent> </Drawer>
</Card>
) : null} <Box sx={{ display: { xs: "block", md: "none" }, p: 2 }}>{activeTool ? activeTool.render() : null}</Box>
{skuExport ? (
<Card>
<CardContent>
<Typography variant="h6">Available SKUs</Typography>
<Box <Box
component="pre" sx={{
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }} display: { xs: "none", md: "grid" },
minHeight: "100vh",
gridTemplateColumns: {
md: "240px minmax(0, 1fr)",
lg: "240px minmax(0, 1fr) 360px"
},
gap: 0,
p: 0
}}
> >
{skuExport} <Box
component="aside"
sx={{
py: 3,
pl: 3,
pr: 1
}}
>
<Typography variant="h6" sx={{ mb: 1 }}>
Tools
</Typography>
<List
disablePadding
sx={{
bgcolor: "#fff",
borderRadius: 1
}}
>
{TOOLS.map((tool) => (
<ListItem key={tool.id} disablePadding>
<Box
role="option"
aria-selected={tool.id === activeTool?.id}
tabIndex={0}
onClick={() => setActiveToolId(tool.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setActiveToolId(tool.id);
}
}}
sx={{
width: "100%",
px: 1.5,
py: 1,
cursor: "pointer",
borderLeft: 2,
borderColor: tool.id === activeTool?.id ? "primary.main" : "transparent"
}}
>
<ListItemText
primary={tool.name}
slotProps={{
primary: {
sx: {
fontWeight: tool.id === activeTool?.id ? 600 : 400
}
}
}}
/>
</Box>
</ListItem>
))}
</List>
</Box>
<Box sx={{ minWidth: 0, p: 3 }}>{activeTool ? activeTool.render() : null}</Box>
<Box
component="aside"
sx={{
gridColumn: { md: "2", lg: "auto" },
p: 3
}}
>
<Typography variant="h6" sx={{ mb: 1 }}>
Help
</Typography>
{activeTool?.helpContent ? (
<Box sx={{ color: "text.secondary", whiteSpace: "pre-wrap" }}>{activeTool.helpContent}</Box>
) : (
<Typography color="text.secondary">No help available for this tool.</Typography>
)}
</Box>
</Box> </Box>
</CardContent>
</Card>
) : null}
</Stack>
</Container>
</Box> </Box>
); );
}; };

View File

@@ -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", {

View File

@@ -0,0 +1 @@
Use this tool to select an Azure marketplace image and generate reusable snippets.

View File

@@ -0,0 +1 @@
Use this tool to list VM SKUs available in a selected Azure region.

View 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`;
};

View File

@@ -0,0 +1,16 @@
import { Stack, Typography } from "@mui/material";
const EmptyTool = () => {
return (
<Stack spacing={1}>
<Typography variant="h4" sx={{ fontWeight: 500 }}>
Empty Tool
</Typography>
<Typography color="text.secondary">
This tool is intentionally empty.
</Typography>
</Stack>
);
};
export default EmptyTool;

View File

@@ -0,0 +1,295 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Autocomplete,
Box,
Button,
Card,
CardContent,
CircularProgress,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography
} 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 = {
location: "",
publisher: "",
offer: "",
sku: "",
version: ""
};
const ImageChooserTool = () => {
const [selection, setSelection] = useState<SelectionState>(() => ({
...EMPTY_SELECTION,
location: readSharedLocation()
}));
const [templateFile, setTemplateFile] = useState("");
const [renderedUsage, setRenderedUsage] = useState("");
const [skuExport, setSkuExport] = useState("");
const [renderError, setRenderError] = useState("");
const healthQuery = useQuery({ queryKey: ["health"], queryFn: api.health });
const locationsQuery = useQuery({ queryKey: ["locations"], queryFn: api.locations });
const publishersQuery = useQuery({
queryKey: ["publishers", selection.location],
queryFn: () => api.publishers(selection.location),
enabled: Boolean(selection.location)
});
const offersQuery = useQuery({
queryKey: ["offers", selection.location, selection.publisher],
queryFn: () => api.offers(selection.location, selection.publisher),
enabled: Boolean(selection.location && selection.publisher)
});
const skusQuery = useQuery({
queryKey: ["skus", selection.location, selection.publisher, selection.offer],
queryFn: () => api.skus(selection.location, selection.publisher, selection.offer),
enabled: Boolean(selection.location && selection.publisher && selection.offer)
});
const versionsQuery = useQuery({
queryKey: ["versions", selection.location, selection.publisher, selection.offer, selection.sku],
queryFn: () => api.versions(selection.location, selection.publisher, selection.offer, selection.sku),
enabled: Boolean(selection.location && selection.publisher && selection.offer && selection.sku)
});
const templatesQuery = useQuery({ queryKey: ["templates"], queryFn: api.templates });
useEffect(() => {
setSelection((previous) => ({ ...previous, publisher: "", offer: "", sku: "", version: "" }));
setRenderedUsage("");
setSkuExport("");
}, [selection.location]);
useEffect(() => {
setSelection((previous) => ({ ...previous, offer: "", sku: "", version: "" }));
setRenderedUsage("");
setSkuExport("");
}, [selection.publisher]);
useEffect(() => {
setSelection((previous) => ({ ...previous, sku: "", version: "" }));
setRenderedUsage("");
}, [selection.offer]);
useEffect(() => {
setSelection((previous) => ({ ...previous, version: "" }));
setRenderedUsage("");
}, [selection.sku]);
const canRender = useMemo(
() => Boolean(selection.location && selection.publisher && selection.offer && selection.sku && selection.version && templateFile),
[selection, templateFile]
);
const publishers = useMemo(
() => [...(publishersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
[publishersQuery.data]
);
const offers = useMemo(
() => [...(offersQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
[offersQuery.data]
);
const skus = useMemo(
() => [...(skusQuery.data ?? [])].sort((a, b) => a.localeCompare(b)),
[skusQuery.data]
);
const versions = useMemo(
() => [...(versionsQuery.data ?? [])].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })),
[versionsQuery.data]
);
const containsFilter = (options: string[], inputValue: string) => {
const query = inputValue.trim().toLowerCase();
if (!query) {
return options;
}
return options.filter((option) => option.toLowerCase().includes(query));
};
const onRender = async () => {
setRenderError("");
try {
const [usage, skuBlock] = await Promise.all([
api.render(templateFile, selection),
api.skuExport(selection.location, selection.publisher, selection.offer)
]);
setRenderedUsage(usage);
setSkuExport(skuBlock);
} catch (error) {
setRenderError(error instanceof Error ? error.message : "Unexpected render error");
}
};
const loading = locationsQuery.isLoading || templatesQuery.isLoading;
const healthError = healthQuery.error instanceof Error ? healthQuery.error.message : "";
const appNotConfigured = healthError.includes("Missing AZURE_SUBSCRIPTION_ID");
const locationsError = locationsQuery.error instanceof Error ? locationsQuery.error.message : "Failed to load locations";
return (
<Stack spacing={3}>
<Typography variant="h4" sx={{ fontWeight: 500 }}>
Azure Image Chooser
</Typography>
<Typography color="text.secondary">
Select a marketplace image and generate reusable snippets.
</Typography>
{appNotConfigured ? (
<Alert severity="warning">
App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app.
</Alert>
) : null}
{loading ? (
<CircularProgress />
) : locationsQuery.isError ? (
<Alert severity={appNotConfigured ? "warning" : "error"}>{locationsError}</Alert>
) : (
<Card>
<CardContent>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel id="location-label">Location</InputLabel>
<Select
labelId="location-label"
label="Location"
value={selection.location}
onChange={(event) => {
const nextLocation = event.target.value;
setSelection((prev) => ({ ...prev, location: nextLocation }));
writeSharedLocation(nextLocation);
}}
>
{(locationsQuery.data ?? []).map((location) => (
<MenuItem key={location.name} value={location.name}>
{location.displayName}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Autocomplete
options={publishers}
value={selection.publisher || null}
disabled={!selection.location}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, publisher: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="Publisher" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Autocomplete
options={offers}
value={selection.offer || null}
disabled={!selection.publisher}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, offer: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="Offer" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Autocomplete
options={skus}
value={selection.sku || null}
disabled={!selection.offer}
onChange={(_event, value) => setSelection((prev) => ({ ...prev, sku: value ?? "" }))}
filterOptions={(options, state) => containsFilter(options, state.inputValue)}
renderInput={(params) => <TextField {...params} label="SKU" />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!selection.sku}>
<InputLabel id="version-label">Version</InputLabel>
<Select
labelId="version-label"
label="Version"
value={selection.version}
onChange={(event) => setSelection((prev) => ({ ...prev, version: event.target.value }))}
>
{versions.map((version) => (
<MenuItem key={version} value={version}>
{version}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<FormControl fullWidth>
<InputLabel id="template-label">Usage scenario</InputLabel>
<Select
labelId="template-label"
label="Usage scenario"
value={templateFile}
onChange={(event) => setTemplateFile(event.target.value)}
>
{(templatesQuery.data ?? []).map((template) => (
<MenuItem key={template.file} value={template.file}>
{template.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Button fullWidth variant="contained" sx={{ height: "100%" }} color="primary" onClick={onRender} disabled={!canRender}>
Generate usage
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
)}
{renderError ? <Alert severity="error">{renderError}</Alert> : null}
{renderedUsage ? (
<Card>
<CardContent>
<Typography variant="h6">Usage snippet</Typography>
<Box
component="pre"
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
>
{renderedUsage}
</Box>
</CardContent>
</Card>
) : null}
{skuExport ? (
<Card>
<CardContent>
<Typography variant="h6">Available SKUs</Typography>
<Box
component="pre"
sx={{ overflowX: "auto", p: 2, bgcolor: "grey.100", color: "text.primary", borderRadius: 1, border: 1, borderColor: "divider" }}
>
{skuExport}
</Box>
</CardContent>
</Card>
) : null}
</Stack>
);
};
export default ImageChooserTool;

View File

@@ -0,0 +1,288 @@
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;
vcpus: number;
memoryGb: number;
maxDataDiskCount: number;
};
const formatValue = (value: number | null): string => {
if (value === null) {
return "-";
}
return Number.isInteger(value) ? String(value) : value.toFixed(1);
};
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 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();
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;
}
return true;
});
}, [vmSkusQuery.data, nameFilter, familyFilter]);
const rows = useMemo<VmSkuRow[]>(
() =>
filteredSkus.map((sku) => ({
id: sku.name || `${sku.size || "sku"}-${sku.family || "unknown"}`,
name: sku.name || "-",
vcpus: sku.vcpus,
memoryGb: sku.memoryGb,
maxDataDiskCount: sku.maxDataDiskCount
})),
[filteredSkus]
);
const columns = useMemo<GridColDef<VmSkuRow>[]>(
() => [
{
field: "name",
headerName: "SKU",
minWidth: 220,
flex: 1.4,
headerClassName: HEADER_CLASS_NAME
},
{
field: "vcpus",
headerName: "vCPUs",
type: "number",
minWidth: 110,
align: "right",
headerAlign: "right",
valueFormatter: (value) => formatValue(typeof value === "number" ? value : null),
headerClassName: HEADER_CLASS_NAME
},
{
field: "memoryGb",
headerName: "Memory (GB)",
type: "number",
minWidth: 130,
align: "right",
headerAlign: "right",
valueFormatter: (value) => formatValue(typeof value === "number" ? value : null),
headerClassName: HEADER_CLASS_NAME
},
{
field: "maxDataDiskCount",
headerName: "Max Data Disks",
type: "number",
minWidth: 150,
align: "right",
headerAlign: "right",
valueFormatter: (value) => formatValue(typeof value === "number" ? value : null),
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))"
},
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>
</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;

View File

@@ -0,0 +1,33 @@
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",
name: "Azure Image Chooser",
helpContent: imageChooserHelp,
render: () => <ImageChooserTool />
},
{
id: "vm-sku-chooser",
name: "VM SKU Chooser",
helpContent: vmSkuChooserHelp,
render: () => <VmSkuChooserTool />
},
...(!isProductionMode
? [
{
id: "empty-tool",
name: "Empty Tool",
render: () => <EmptyTool />
}
]
: [])
];

View File

@@ -0,0 +1,8 @@
import type { ReactElement } from "react";
export type ToolDefinition = {
id: string;
name: string;
helpContent?: string;
render: () => ReactElement;
};

View File

@@ -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;
memoryGb: number;
maxDataDiskCount: number;
};

View File

@@ -8,6 +8,7 @@ export default defineConfig({
emptyOutDir: true emptyOutDir: true
}, },
server: { server: {
host: "0.0.0.0",
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
@@ -15,5 +16,9 @@ export default defineConfig({
changeOrigin: true changeOrigin: true
} }
} }
},
preview: {
host: "0.0.0.0",
port: 5173
} }
}); });