Compare commits
16 Commits
055f51aa55
...
feature/vm
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d9a3d014 | |||
| 778a74c49a | |||
| cc4ff948c5 | |||
| 5304b07d17 | |||
| 3984b8684f | |||
| 8079162117 | |||
| 733ad9b6b8 | |||
| f64f9bd3e8 | |||
| de9ba78089 | |||
| 3c4e10eda7 | |||
| 287dfc0b8b | |||
| 5561f10958 | |||
| 85dd574991 | |||
| 4f97dc3362 | |||
| 9100f71ab5 | |||
| 0d12f24dec |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,3 +21,5 @@ azure.env
|
|||||||
# Node/React rewrite outputs
|
# Node/React rewrite outputs
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sławomir Koszewski
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
84
README.md
84
README.md
@@ -1,46 +1,78 @@
|
|||||||
# Azure Image Chooser
|
# Azure Image Chooser
|
||||||
|
|
||||||
Azure Image Chooser is a [Streamlit](https://streamlit.io) application that allows users to select Azure VM images from the Azure Marketplace.
|
Azure Image Chooser is a Node.js application that allows users to select Azure VM images from the Azure Marketplace.
|
||||||
|
|
||||||
Azure Image Chooser is written in Python and requires Python interpreter. At the time of writing this, Python 3.13 is the latest.
|
Azure Image Chooser has a TypeScript backend (Express) and a React frontend (Vite). At the time of writing this, Node.js 24 is used by the container image and is the recommended version for local runs.
|
||||||
|
|
||||||
You can run it on your local machine or deploy to any platform that runs containers. A Docker file and Terraform code to deploy to the Azure are provided.
|
You can run it on your local machine or deploy to any platform that runs containers.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Required for both local and container run:
|
||||||
|
|
||||||
|
- `AZURE_SUBSCRIPTION_ID`: Azure subscription used for Marketplace queries.
|
||||||
|
|
||||||
|
Authentication variables (only needed when identity is not provided by the environment):
|
||||||
|
|
||||||
|
- `AZURE_TENANT_ID`: Microsoft Entra tenant ID for service principal auth.
|
||||||
|
- `AZURE_CLIENT_ID`: Service principal (app registration) client ID.
|
||||||
|
- `AZURE_CLIENT_SECRET`: Service principal client secret.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `PORT`: Backend listening port. Default is `3000`.
|
||||||
|
|
||||||
|
Local run notes:
|
||||||
|
|
||||||
|
- `AZURE_SUBSCRIPTION_ID` must be set.
|
||||||
|
- Use either Azure CLI login (`az login`) or the service principal variables above.
|
||||||
|
|
||||||
|
Container run notes:
|
||||||
|
|
||||||
|
- `AZURE_SUBSCRIPTION_ID` must be passed to the container.
|
||||||
|
- Pass `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` unless your container runtime provides identity (for example Managed Identity / Workload Identity).
|
||||||
|
|
||||||
## Running on a local machine
|
## Running on a local machine
|
||||||
|
|
||||||
Create a Python development environment file `.env`:
|
Load environment variables from the repository environment file:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
AZURE_SUBSCRIPTION_ID="subscription_id"
|
set -a; source azure.env; set +a
|
||||||
AZURE_CLIENT_ID="client_id"
|
|
||||||
AZURE_CLIENT_SECRET="client_secret"
|
|
||||||
AZURE_TENANT_ID="tenant_id"
|
|
||||||
AZURE_LOCATION="westeurope"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> NOTE: Replace the placeholder values with your actual values. Omit `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID` if you are using Azure CLI authentication.
|
Execute the following commands to install dependencies, build, and run the app:
|
||||||
|
|
||||||
Execute the following commands to run the app:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
python3 -m venv .venv
|
cd app/backend
|
||||||
source .venv/bin/activate
|
npm ci
|
||||||
python -m pip install pip --upgrade
|
|
||||||
pip install -r requirements.txt
|
cd ../frontend
|
||||||
cd app
|
npm ci
|
||||||
streamlit run image-chooser.py
|
|
||||||
|
cd backend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ../frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ../backend
|
||||||
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
The app will block terminal and start a web server. Follow the instructions in the terminal to access the app.
|
The app will block the terminal and start a web server on port 3000. Open http://localhost:3000 in your browser.
|
||||||
|
|
||||||
## Add Certificate Binding for Azure Deployment
|
## Running with Docker
|
||||||
|
|
||||||
Unfortunately, as of now, the Terraform Azure Resource Manager Provider does not support binding certificates to container apps. You can still bind the certificate using the Azure CLI.
|
Build and run the container:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
# Find the certificate name
|
docker build -t azure-image-chooser-node ./app
|
||||||
CERTIFICATE_LOWERCASE_NAME=$(az containerapp env certificate list -g $RESOURCE_GROUP -n $ENVIRONMENT --query '[].name' -o tsv)
|
docker run --rm -p 3000:3000 \
|
||||||
|
-e AZURE_SUBSCRIPTION_ID="subscription_id" \
|
||||||
# Bind the certificate to the container app
|
-e AZURE_CLIENT_ID="client_id" \
|
||||||
az containerapp hostname bind --hostname $DOMAIN_NAME -g $RESOURCE_GROUP -n $CONTAINER_APP --environment $ENVIRONMENT --certificate $CERTIFICATE_LOWERCASE_NAME --validation-method CNAME
|
-e AZURE_CLIENT_SECRET="client_secret" \
|
||||||
|
-e AZURE_TENANT_ID="tenant_id" \
|
||||||
|
azure-image-chooser-node
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> NOTE: As with local runs, you can omit AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID when the runtime environment already provides Azure credentials.
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
FROM node:24-trixie-slim AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY app-new/backend/package*.json backend/
|
|
||||||
COPY app-new/frontend/package*.json frontend/
|
|
||||||
RUN cd backend && npm ci
|
|
||||||
RUN cd frontend && npm ci
|
|
||||||
|
|
||||||
COPY app-new .
|
|
||||||
|
|
||||||
RUN cd backend && npm run build
|
|
||||||
RUN cd frontend && npm run build
|
|
||||||
RUN cd backend && npm prune --omit=dev
|
|
||||||
|
|
||||||
FROM node:24-trixie-slim AS runtime
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
|
|
||||||
COPY --from=build /app/dist dist
|
|
||||||
COPY --from=build /app/templates templates
|
|
||||||
COPY --from=build /app/templates.json templates.json
|
|
||||||
COPY --from=build /app/backend/node_modules dist/backend/node_modules
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY entrypoint.sh entrypoint.sh
|
|
||||||
COPY healthcheck.js healthcheck.js
|
|
||||||
RUN chmod +x entrypoint.sh
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["node", "/app/healthcheck.js"]
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
exec node /app/dist/backend/server.js
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Autocomplete,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CircularProgress,
|
|
||||||
Container,
|
|
||||||
FormControl,
|
|
||||||
Grid,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { api } from "./api";
|
|
||||||
import type { SelectionState } from "./types";
|
|
||||||
|
|
||||||
const EMPTY_SELECTION: SelectionState = {
|
|
||||||
location: "",
|
|
||||||
publisher: "",
|
|
||||||
offer: "",
|
|
||||||
sku: "",
|
|
||||||
version: ""
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [selection, setSelection] = useState<SelectionState>(EMPTY_SELECTION);
|
|
||||||
const [templateFile, setTemplateFile] = useState("");
|
|
||||||
const [renderedUsage, setRenderedUsage] = useState("");
|
|
||||||
const [skuExport, setSkuExport] = useState("");
|
|
||||||
const [renderError, setRenderError] = useState("");
|
|
||||||
|
|
||||||
const 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 (
|
|
||||||
<Box sx={{ minHeight: "100vh", py: 4 }}>
|
|
||||||
<Container maxWidth="lg">
|
|
||||||
<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) => 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>
|
|
||||||
</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>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"label": "Terraform VM image reference",
|
|
||||||
"language": "hcl",
|
|
||||||
"file": "azurerm_hcl.tpl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Azure CLI",
|
|
||||||
"language": "shell",
|
|
||||||
"file": "shell.tpl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Azure Resource Manager Template",
|
|
||||||
"language": "json",
|
|
||||||
"file": "arm_vm.jsonc"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Bicep VM image reference",
|
|
||||||
"language": "bicep",
|
|
||||||
"file": "bicep_vm.tpl"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
15
app-streamlit/Dockerfile
Normal file
15
app-streamlit/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN pip install --root-user-action=ignore --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY image-chooser.py .
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY templates.json .
|
||||||
|
COPY ./entrypoint.sh /
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||||
|
CMD [ "run", "image-chooser.py" ]
|
||||||
46
app-streamlit/README.md
Normal file
46
app-streamlit/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Azure Image Chooser
|
||||||
|
|
||||||
|
Azure Image Chooser is a [Streamlit](https://streamlit.io) application that allows users to select Azure VM images from the Azure Marketplace.
|
||||||
|
|
||||||
|
Azure Image Chooser is written in Python and requires Python interpreter. At the time of writing this, Python 3.13 is the latest.
|
||||||
|
|
||||||
|
You can run it on your local machine or deploy to any platform that runs containers. A Docker file and Terraform code to deploy to the Azure are provided.
|
||||||
|
|
||||||
|
## Running on a local machine
|
||||||
|
|
||||||
|
Create a Python development environment file `.env`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
AZURE_SUBSCRIPTION_ID="subscription_id"
|
||||||
|
AZURE_CLIENT_ID="client_id"
|
||||||
|
AZURE_CLIENT_SECRET="client_secret"
|
||||||
|
AZURE_TENANT_ID="tenant_id"
|
||||||
|
AZURE_LOCATION="westeurope"
|
||||||
|
```
|
||||||
|
|
||||||
|
> NOTE: Replace the placeholder values with your actual values. Omit `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and `AZURE_TENANT_ID` if you are using Azure CLI authentication.
|
||||||
|
|
||||||
|
Execute the following commands to run the app:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install pip --upgrade
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cd app
|
||||||
|
streamlit run image-chooser.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will block terminal and start a web server. Follow the instructions in the terminal to access the app.
|
||||||
|
|
||||||
|
## Add Certificate Binding for Azure Deployment
|
||||||
|
|
||||||
|
Unfortunately, as of now, the Terraform Azure Resource Manager Provider does not support binding certificates to container apps. You can still bind the certificate using the Azure CLI.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Find the certificate name
|
||||||
|
CERTIFICATE_LOWERCASE_NAME=$(az containerapp env certificate list -g $RESOURCE_GROUP -n $ENVIRONMENT --query '[].name' -o tsv)
|
||||||
|
|
||||||
|
# Bind the certificate to the container app
|
||||||
|
az containerapp hostname bind --hostname $DOMAIN_NAME -g $RESOURCE_GROUP -n $CONTAINER_APP --environment $ENVIRONMENT --certificate $CERTIFICATE_LOWERCASE_NAME --validation-method CNAME
|
||||||
|
```
|
||||||
8
app-streamlit/entrypoint.sh
Executable file
8
app-streamlit/entrypoint.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ "$1" = "-s" ] || [ "$1" = "--shell" ]; then
|
||||||
|
shift
|
||||||
|
exec bash $@
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec streamlit $@
|
||||||
17
app-streamlit/templates.json
Normal file
17
app-streamlit/templates.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "Terraform VM image reference",
|
||||||
|
"language": "hcl",
|
||||||
|
"file": "azurerm_hcl.tpl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Azure CLI",
|
||||||
|
"language": "shell",
|
||||||
|
"file": "shell.tpl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Azure Resource Manager Template",
|
||||||
|
"language": "json",
|
||||||
|
"file": "arm_vm.jsonc"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1 +1 @@
|
|||||||
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
||||||
@@ -1,15 +1,50 @@
|
|||||||
FROM python:3.13-slim
|
# Build stage
|
||||||
|
FROM node:24-trixie-slim AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
# Copy sources required for build
|
||||||
|
COPY backend/*.json backend/
|
||||||
|
COPY backend/src backend/src
|
||||||
|
|
||||||
RUN pip install --root-user-action=ignore --no-cache-dir -r requirements.txt
|
COPY frontend/*.json frontend/
|
||||||
|
COPY frontend/vite.config.ts frontend/vite.config.ts
|
||||||
|
COPY frontend/index.html frontend/index.html
|
||||||
|
COPY frontend/src frontend/src
|
||||||
|
COPY frontend/test frontend/test
|
||||||
|
|
||||||
COPY image-chooser.py .
|
COPY templates templates
|
||||||
COPY templates/ templates/
|
COPY templates.json templates.json
|
||||||
COPY templates.json .
|
|
||||||
COPY ./entrypoint.sh /
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
# Build backend and frontend
|
||||||
CMD [ "run", "image-chooser.py" ]
|
RUN cd backend && npm ci && npm run build && npm prune --omit=dev
|
||||||
|
RUN cd frontend && npm ci && npm run build
|
||||||
|
|
||||||
|
# Build the container
|
||||||
|
FROM node:24-trixie-slim AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built artifacts
|
||||||
|
COPY --from=build /app/dist dist
|
||||||
|
COPY --from=build /app/templates templates
|
||||||
|
COPY --from=build /app/templates.json templates.json
|
||||||
|
COPY --from=build /app/backend/node_modules dist/backend/node_modules
|
||||||
|
|
||||||
|
# Copy entrypoint and healthcheck scripts
|
||||||
|
COPY entrypoint.sh entrypoint.sh
|
||||||
|
COPY healthcheck.js healthcheck.js
|
||||||
|
|
||||||
|
# Ensure entrypoint script is executable
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
# Set environment variables and expose port
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Configure health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["node", "/app/healthcheck.js"]
|
||||||
|
|
||||||
|
# Configure entrypoint
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-image-chooser-backend",
|
"name": "azure-image-chooser-backend",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
|
"author": "Sławomir Koszewski",
|
||||||
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|||||||
import { AzureImageService } from "./azure-service";
|
import { AzureImageService } from "./azure-service";
|
||||||
import { TemplateService } from "./template-service";
|
import { TemplateService } from "./template-service";
|
||||||
|
|
||||||
const findAppNewRoot = (): string => {
|
const findAppRoot = (): string => {
|
||||||
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -15,7 +15,7 @@ const findAppNewRoot = (): string => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Unable to resolve app-new root");
|
throw new Error("Unable to resolve app root");
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryLocation = z.object({ location: z.string().min(1) });
|
const queryLocation = z.object({ location: z.string().min(1) });
|
||||||
@@ -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());
|
||||||
});
|
});
|
||||||
@@ -143,7 +153,7 @@ const makeApp = () => {
|
|||||||
res.status(500).json({ message });
|
res.status(500).json({ message });
|
||||||
});
|
});
|
||||||
|
|
||||||
const frontendRoot = join(findAppNewRoot(), "dist/frontend");
|
const frontendRoot = join(findAppRoot(), "dist/frontend");
|
||||||
if (existsSync(frontendRoot)) {
|
if (existsSync(frontendRoot)) {
|
||||||
app.use(express.static(frontendRoot));
|
app.use(express.static(frontendRoot));
|
||||||
app.get(/^(?!\/api).*/, (_req, res) => {
|
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||||
@@ -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;
|
||||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||||||
import nunjucks from "nunjucks";
|
import nunjucks from "nunjucks";
|
||||||
import type { ImageSelection, UsageTemplate } from "./types";
|
import type { ImageSelection, UsageTemplate } from "./types";
|
||||||
|
|
||||||
const findAppNewRoot = (): string => {
|
const findAppRoot = (): string => {
|
||||||
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -12,19 +12,19 @@ const findAppNewRoot = (): string => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Unable to resolve app-new template root");
|
throw new Error("Unable to resolve app template root");
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
private readonly appNewRoot = findAppNewRoot();
|
private readonly appRoot = findAppRoot();
|
||||||
|
|
||||||
private readonly env = nunjucks.configure(join(this.appNewRoot, "templates"), {
|
private readonly env = nunjucks.configure(join(this.appRoot, "templates"), {
|
||||||
autoescape: false,
|
autoescape: false,
|
||||||
noCache: true
|
noCache: true
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly templates: UsageTemplate[] = JSON.parse(
|
private readonly templates: UsageTemplate[] = JSON.parse(
|
||||||
readFileSync(join(this.appNewRoot, "templates.json"), "utf8")
|
readFileSync(join(this.appRoot, "templates.json"), "utf8")
|
||||||
) as UsageTemplate[];
|
) as UsageTemplate[];
|
||||||
|
|
||||||
public getTemplates(): UsageTemplate[] {
|
public getTemplates(): UsageTemplate[] {
|
||||||
@@ -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
18
app/build.sh
Executable 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
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
if [ "$1" = "-s" ] || [ "$1" = "--shell" ]; then
|
exec node /app/dist/backend/server.js
|
||||||
shift
|
|
||||||
exec bash $@
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec streamlit $@
|
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-image-chooser-frontend",
|
"name": "azure-image-chooser-frontend",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
|
"author": "Sławomir Koszewski",
|
||||||
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,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"
|
||||||
196
app/frontend/src/App.tsx
Normal file
196
app/frontend/src/App.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Toolbar,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import { TOOLS } from "./tools/toolRegistry";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [activeToolId, setActiveToolId] = useState<string>(TOOLS[0]?.id ?? "");
|
||||||
|
const [toolsAnchorEl, setToolsAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
|
const activeTool = useMemo(
|
||||||
|
() => TOOLS.find((tool) => tool.id === activeToolId) ?? TOOLS[0],
|
||||||
|
[activeToolId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolsMenuOpen = Boolean(toolsAnchorEl);
|
||||||
|
|
||||||
|
const openToolsMenu = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setToolsAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeToolsMenu = () => {
|
||||||
|
setToolsAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTool = (toolId: string) => {
|
||||||
|
setActiveToolId(toolId);
|
||||||
|
closeToolsMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
|
<AppBar
|
||||||
|
position="sticky"
|
||||||
|
color="default"
|
||||||
|
elevation={0}
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider", display: { xs: "block", md: "none" } }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Button variant="text" color="inherit" onClick={() => setHelpOpen(true)}>
|
||||||
|
Help
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<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 }}>
|
||||||
|
<ListItemButton onClick={() => setHelpOpen(false)}>
|
||||||
|
<ListItemText primary="Close" />
|
||||||
|
</ListItemButton>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Box sx={{ display: { xs: "block", md: "none" }, p: 2 }}>{activeTool ? activeTool.render() : null}</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", md: "grid" },
|
||||||
|
minHeight: "100vh",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
md: "240px minmax(0, 1fr)",
|
||||||
|
lg: "240px minmax(0, 1fr) 360px"
|
||||||
|
},
|
||||||
|
gap: 0,
|
||||||
|
p: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -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/image-chooser-help.md
Normal file
1
app/frontend/src/help/image-chooser-help.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Use this tool to select an Azure marketplace image and generate reusable snippets.
|
||||||
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`;
|
||||||
|
};
|
||||||
16
app/frontend/src/tools/EmptyTool.tsx
Normal file
16
app/frontend/src/tools/EmptyTool.tsx
Normal 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;
|
||||||
295
app/frontend/src/tools/ImageChooserTool.tsx
Normal file
295
app/frontend/src/tools/ImageChooserTool.tsx
Normal 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;
|
||||||
288
app/frontend/src/tools/VmSkuChooserTool.tsx
Normal file
288
app/frontend/src/tools/VmSkuChooserTool.tsx
Normal 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;
|
||||||
33
app/frontend/src/tools/toolRegistry.tsx
Normal file
33
app/frontend/src/tools/toolRegistry.tsx
Normal 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 />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
];
|
||||||
8
app/frontend/src/tools/types.ts
Normal file
8
app/frontend/src/tools/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
export type ToolDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
helpContent?: string;
|
||||||
|
render: () => ReactElement;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"label": "Terraform VM image reference",
|
"label": "Terraform VM image reference",
|
||||||
"language": "hcl",
|
"language": "hcl",
|
||||||
"file": "azurerm_hcl.tpl"
|
"file": "azurerm_hcl.tpl"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Azure CLI",
|
"label": "Azure CLI",
|
||||||
"language": "shell",
|
"language": "shell",
|
||||||
"file": "shell.tpl"
|
"file": "shell.tpl"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Azure Resource Manager Template",
|
"label": "Azure Resource Manager Template",
|
||||||
"language": "json",
|
"language": "json",
|
||||||
"file": "arm_vm.jsonc"
|
"file": "arm_vm.jsonc"
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"label": "Bicep VM image reference",
|
||||||
|
"language": "bicep",
|
||||||
|
"file": "bicep_vm.tpl"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
az create -n MyVM -g MyResourceGroup --image {{ publisher }}:{{ offer }}:{{ sku }}:{{ version }}
|
||||||
|
|||||||
Reference in New Issue
Block a user