feat: implement Azure Image Chooser tool with help content and empty tool placeholder
This commit is contained in:
@@ -37,6 +37,7 @@ const renderBody = z.object({
|
||||
const makeApp = () => {
|
||||
const app = express();
|
||||
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;
|
||||
|
||||
app.use(cors());
|
||||
@@ -151,14 +152,14 @@ const makeApp = () => {
|
||||
});
|
||||
}
|
||||
|
||||
return { app, port };
|
||||
return { app, port, host };
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const { app, port } = makeApp();
|
||||
const server = app.listen(port, () => {
|
||||
const { app, port, host } = makeApp();
|
||||
const server = app.listen(port, host, () => {
|
||||
// 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;
|
||||
|
||||
@@ -1,290 +1,109 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
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: ""
|
||||
};
|
||||
import { TOOLS } from "./tools/toolRegistry";
|
||||
|
||||
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 [activeToolId, setActiveToolId] = useState<string>(TOOLS[0]?.id ?? "");
|
||||
|
||||
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 activeTool = useMemo(
|
||||
() => TOOLS.find((tool) => tool.id === activeToolId) ?? TOOLS[0],
|
||||
[activeToolId]
|
||||
);
|
||||
|
||||
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>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
md: "240px minmax(0, 1fr)",
|
||||
lg: "240px minmax(0, 1fr) 360px"
|
||||
},
|
||||
gap: { xs: 2, md: 0 },
|
||||
p: { xs: 2, md: 0 }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="aside"
|
||||
sx={{
|
||||
py: { xs: 0, md: 3 },
|
||||
pl: { xs: 0, md: 3 },
|
||||
pr: { xs: 0, md: 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>
|
||||
|
||||
{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}
|
||||
<Box sx={{ minWidth: 0, p: { xs: 0, md: 3 } }}>{activeTool ? activeTool.render() : null}</Box>
|
||||
|
||||
{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
|
||||
component="aside"
|
||||
sx={{
|
||||
gridColumn: { xs: "1", md: "2", lg: "auto" },
|
||||
p: { xs: 2, md: 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>
|
||||
);
|
||||
};
|
||||
|
||||
10
app/frontend/src/help/image-chooser-help.md
Normal file
10
app/frontend/src/help/image-chooser-help.md
Normal file
@@ -0,0 +1,10 @@
|
||||
Use this tool to select an Azure marketplace image and generate reusable snippets.
|
||||
|
||||
Workflow:
|
||||
1. Select location.
|
||||
2. Select publisher, then offer, then SKU.
|
||||
3. Select version.
|
||||
4. Select usage scenario.
|
||||
5. Generate usage output.
|
||||
|
||||
If configuration warning appears, set AZURE_SUBSCRIPTION_ID and valid Azure credentials in the runtime environment.
|
||||
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;
|
||||
287
app/frontend/src/tools/ImageChooserTool.tsx
Normal file
287
app/frontend/src/tools/ImageChooserTool.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
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 type { SelectionState } from "../types";
|
||||
|
||||
const EMPTY_SELECTION: SelectionState = {
|
||||
location: "",
|
||||
publisher: "",
|
||||
offer: "",
|
||||
sku: "",
|
||||
version: ""
|
||||
};
|
||||
|
||||
const ImageChooserTool = () => {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageChooserTool;
|
||||
18
app/frontend/src/tools/toolRegistry.tsx
Normal file
18
app/frontend/src/tools/toolRegistry.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import ImageChooserTool from "./ImageChooserTool";
|
||||
import EmptyTool from "./EmptyTool";
|
||||
import imageChooserHelp from "../help/image-chooser-help.md?raw";
|
||||
import type { ToolDefinition } from "./types";
|
||||
|
||||
export const TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
id: "image-chooser",
|
||||
name: "Azure Image Chooser",
|
||||
helpContent: imageChooserHelp,
|
||||
render: () => <ImageChooserTool />
|
||||
},
|
||||
{
|
||||
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;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
@@ -15,5 +16,9 @@ export default defineConfig({
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user