diff --git a/app/backend/src/server.ts b/app/backend/src/server.ts index ff0a469..8f7ad2d 100644 --- a/app/backend/src/server.ts +++ b/app/backend/src/server.ts @@ -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; diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index 6bd896f..cb48f64 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -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(EMPTY_SELECTION); - const [templateFile, setTemplateFile] = useState(""); - const [renderedUsage, setRenderedUsage] = useState(""); - const [skuExport, setSkuExport] = useState(""); - const [renderError, setRenderError] = useState(""); + const [activeToolId, setActiveToolId] = useState(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 ( - - - - - Azure Image Chooser - - - Select a marketplace image and generate reusable snippets. - + + + + Tools + + + {TOOLS.map((tool) => ( + + 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" + }} + > + + + + ))} + + - {appNotConfigured ? ( - - App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app. - - ) : null} + {activeTool ? activeTool.render() : null} - {loading ? ( - - ) : locationsQuery.isError ? ( - {locationsError} - ) : ( - - - - - - Location - - - - - - setSelection((prev) => ({ ...prev, publisher: value ?? "" }))} - filterOptions={(options, state) => containsFilter(options, state.inputValue)} - renderInput={(params) => } - /> - - - - setSelection((prev) => ({ ...prev, offer: value ?? "" }))} - filterOptions={(options, state) => containsFilter(options, state.inputValue)} - renderInput={(params) => } - /> - - - - setSelection((prev) => ({ ...prev, sku: value ?? "" }))} - filterOptions={(options, state) => containsFilter(options, state.inputValue)} - renderInput={(params) => } - /> - - - - - Version - - - - - - - Usage scenario - - - - - - - - - - - )} - - {renderError ? {renderError} : null} - - {renderedUsage ? ( - - - Usage snippet - - {renderedUsage} - - - - ) : null} - - {skuExport ? ( - - - Available SKUs - - {skuExport} - - - - ) : null} - - + + + Help + + {activeTool?.helpContent ? ( + {activeTool.helpContent} + ) : ( + No help available for this tool. + )} + ); }; diff --git a/app/frontend/src/help/image-chooser-help.md b/app/frontend/src/help/image-chooser-help.md new file mode 100644 index 0000000..55e073b --- /dev/null +++ b/app/frontend/src/help/image-chooser-help.md @@ -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. diff --git a/app/frontend/src/tools/EmptyTool.tsx b/app/frontend/src/tools/EmptyTool.tsx new file mode 100644 index 0000000..0415093 --- /dev/null +++ b/app/frontend/src/tools/EmptyTool.tsx @@ -0,0 +1,16 @@ +import { Stack, Typography } from "@mui/material"; + +const EmptyTool = () => { + return ( + + + Empty Tool + + + This tool is intentionally empty. + + + ); +}; + +export default EmptyTool; diff --git a/app/frontend/src/tools/ImageChooserTool.tsx b/app/frontend/src/tools/ImageChooserTool.tsx new file mode 100644 index 0000000..800c6df --- /dev/null +++ b/app/frontend/src/tools/ImageChooserTool.tsx @@ -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(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 ( + + + Azure Image Chooser + + + Select a marketplace image and generate reusable snippets. + + + {appNotConfigured ? ( + + App is not configured. Set AZURE_SUBSCRIPTION_ID (and Azure credentials) in the container start environment, then restart the app. + + ) : null} + + {loading ? ( + + ) : locationsQuery.isError ? ( + {locationsError} + ) : ( + + + + + + Location + + + + + + setSelection((prev) => ({ ...prev, publisher: value ?? "" }))} + filterOptions={(options, state) => containsFilter(options, state.inputValue)} + renderInput={(params) => } + /> + + + + setSelection((prev) => ({ ...prev, offer: value ?? "" }))} + filterOptions={(options, state) => containsFilter(options, state.inputValue)} + renderInput={(params) => } + /> + + + + setSelection((prev) => ({ ...prev, sku: value ?? "" }))} + filterOptions={(options, state) => containsFilter(options, state.inputValue)} + renderInput={(params) => } + /> + + + + + Version + + + + + + + Usage scenario + + + + + + + + + + + )} + + {renderError ? {renderError} : null} + + {renderedUsage ? ( + + + Usage snippet + + {renderedUsage} + + + + ) : null} + + {skuExport ? ( + + + Available SKUs + + {skuExport} + + + + ) : null} + + ); +}; + +export default ImageChooserTool; diff --git a/app/frontend/src/tools/toolRegistry.tsx b/app/frontend/src/tools/toolRegistry.tsx new file mode 100644 index 0000000..333e42e --- /dev/null +++ b/app/frontend/src/tools/toolRegistry.tsx @@ -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: () => + }, + { + id: "empty-tool", + name: "Empty Tool", + render: () => + } +]; diff --git a/app/frontend/src/tools/types.ts b/app/frontend/src/tools/types.ts new file mode 100644 index 0000000..208055e --- /dev/null +++ b/app/frontend/src/tools/types.ts @@ -0,0 +1,8 @@ +import type { ReactElement } from "react"; + +export type ToolDefinition = { + id: string; + name: string; + helpContent?: string; + render: () => ReactElement; +}; diff --git a/app/frontend/vite.config.ts b/app/frontend/vite.config.ts index d92ba0b..5b58493 100644 --- a/app/frontend/vite.config.ts +++ b/app/frontend/vite.config.ts @@ -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 } });