diff --git a/src/api/httpClient.ts b/src/api/httpClient.ts index 1c76bc1..bf3c071 100644 --- a/src/api/httpClient.ts +++ b/src/api/httpClient.ts @@ -1,4 +1,4 @@ -const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api"; +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api"; export class ApiError extends Error { constructor( @@ -116,3 +116,4 @@ export async function deleteJson(path: string): Promise(`/Deployment/QueueJobs/Steps/${stepId}/Approve`, { comment }), rejectDeploymentJobStep: (stepId: string, comment?: string) => postJson<{ comment?: string }, void>(`/Deployment/QueueJobs/Steps/${stepId}/Reject`, { comment }), - getDeploymentBatches: (signal?: AbortSignal) => getJson("/DeploymentGroup", signal), + getDeploymentBatches: (signal?: AbortSignal) => getJson("/deployment-batches", signal), getDeploymentBatchById: (deploymentBatchId: string, signal?: AbortSignal) => - getJson(`/DeploymentGroup/${deploymentBatchId}`, signal), - addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson("/DeploymentGroup", deploymentBatch), + getJson(`/deployment-batches/${deploymentBatchId}`, signal), + addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson("/deployment-batches", deploymentBatch), + deleteDeploymentBatch: (deploymentBatchId: string) => deleteJson(`/deployment-batches/${deploymentBatchId}`), + addDeploymentTemplateSelection: (deploymentBatchId: string, templateSelection: AddDeploymentTemplateSelection) => + postJson(`/deployment-batches/${deploymentBatchId}/template-selections`, templateSelection), + deleteDeploymentTemplateSelection: (deploymentBatchId: string, templateSelectionId: string) => + deleteJson(`/deployment-batches/${deploymentBatchId}/template-selections/${templateSelectionId}`), + addDeploymentParameterValue: (deploymentBatchId: string, parameterValue: AddDeploymentParameterValue) => + postJson(`/deployment-batches/${deploymentBatchId}/parameter-values`, parameterValue), + deleteDeploymentParameterValue: (deploymentBatchId: string, parameterValueId: string) => + deleteJson(`/deployment-batches/${deploymentBatchId}/parameter-values/${parameterValueId}`), + addDeploymentTargetAssignment: (deploymentBatchId: string, targetAssignment: AddDeploymentTargetAssignment) => + postJson(`/deployment-batches/${deploymentBatchId}/target-assignments`, targetAssignment), + deleteDeploymentTargetAssignment: (deploymentBatchId: string, targetAssignmentId: string) => + deleteJson(`/deployment-batches/${deploymentBatchId}/target-assignments/${targetAssignmentId}`), + deleteDeploymentExecution: (deploymentBatchId: string, targetId: string) => + deleteJson(`/Deployment/Batch/${deploymentBatchId}/Target/${targetId}`), + getDeploymentRules: (signal?: AbortSignal) => getJson("/deployment-rules", signal), + addDeploymentRule: (rule: AddDeploymentRule) => postJson("/deployment-rules", rule), + updateDeploymentRule: (ruleId: string, rule: AddDeploymentRule) => putJson(`/deployment-rules/${ruleId}`, rule), + deleteDeploymentRule: (ruleId: string) => deleteJson(`/deployment-rules/${ruleId}`), getDomains: (signal?: AbortSignal) => getJson("/Domain", signal), getDomainEnvironments: (domainId: string, signal?: AbortSignal) => getJson(`/Domain/${domainId}/Environments`, signal), - getDomainVirtualMachines: (domainId: string, signal?: AbortSignal) => getJson(`/Domain/${domainId}/VirtualMachines`, signal), + getDomainTargets: (domainId: string, signal?: AbortSignal) => getJson(`/Domain/${domainId}/Targets`, signal), addDomain: (domain: AddDomain) => postJson("/Domain", domain), updateDomain: (domainId: string, domain: AddDomain) => putJson(`/Domain/${domainId}`, domain), deleteDomain: (domainId: string) => deleteJson(`/Domain/${domainId}`), linkDomainToEnvironment: (domainId: string, environmentId: string) => postJson(`/Domain/${domainId}/Environment/${environmentId}`, undefined), + unlinkDomainFromEnvironment: (domainId: string, environmentId: string) => + deleteJson(`/Domain/${domainId}/Environment/${environmentId}`), getEnvironments: (signal?: AbortSignal) => getJson("/Environment", signal), getEnvironmentDomains: (environmentId: string, signal?: AbortSignal) => getJson(`/Environment/${environmentId}/Domains`, signal), addEnvironment: (environment: AddEnvironment) => postJson("/Environment", environment), @@ -60,6 +87,7 @@ export const portalApi = { getRunbooks: (signal?: AbortSignal) => getJson("/Runbook", signal), addRunbook: (runbook: AddRunbook) => postJson("/Runbook", runbook), getTemplates: (signal?: AbortSignal) => getJson("/Template", signal), + getTemplateVersions: (templateId: string, signal?: AbortSignal) => getJson(`/Template/${templateId}/Versions`, signal), addTemplate: (template: AddTemplate) => postJson("/Template", template), updateTemplate: (templateId: string, template: AddTemplate) => putJson(`/Template/${templateId}`, template), deleteTemplate: (templateId: string) => deleteJson(`/Template/${templateId}`), @@ -80,13 +108,14 @@ export const portalApi = { updateTemplateCategory: (templateCategoryId: string, templateCategory: AddTemplateCategory) => putJson(`/TemplateCategory/${templateCategoryId}`, templateCategory), deleteTemplateCategory: (templateCategoryId: string) => deleteJson(`/TemplateCategory/${templateCategoryId}`), - getVirtualMachines: (signal?: AbortSignal) => getJson("/VirtualMachine", signal), - getVirtualMachineById: (virtualMachineId: string, signal?: AbortSignal) => getJson(`/VirtualMachine/${virtualMachineId}`, signal), - addVirtualMachine: (virtualMachine: AddVirtualMachine) => postJson("/VirtualMachine", virtualMachine), - updateVirtualMachine: (virtualMachineId: string, virtualMachine: AddVirtualMachine) => - putJson(`/VirtualMachine/${virtualMachineId}`, virtualMachine), - deleteVirtualMachine: (virtualMachineId: string) => deleteJson(`/VirtualMachine/${virtualMachineId}`), - linkVirtualMachineToDomain: (virtualMachineId: string, domainId: string) => - postJson(`/VirtualMachine/${virtualMachineId}/Domain/${domainId}`, undefined), - unlinkVirtualMachineFromDomain: (virtualMachineId: string) => deleteJson(`/VirtualMachine/${virtualMachineId}/Domain`), + getTargets: (signal?: AbortSignal) => getJson("/Target", signal), + getTargetById: (targetId: string, signal?: AbortSignal) => getJson(`/Target/${targetId}`, signal), + addTarget: (target: AddTarget) => postJson("/Target", target), + updateTarget: (targetId: string, target: AddTarget) => + putJson(`/Target/${targetId}`, target), + deleteTarget: (targetId: string) => deleteJson(`/Target/${targetId}`), + linkTargetToDomain: (targetId: string, domainId: string) => + postJson(`/Target/${targetId}/Domain/${domainId}`, undefined), + unlinkTargetFromDomain: (targetId: string) => deleteJson(`/Target/${targetId}/Domain`), }; + diff --git a/src/components/DataState.tsx b/src/components/DataState.tsx index 5ee44a5..a9f2420 100644 --- a/src/components/DataState.tsx +++ b/src/components/DataState.tsx @@ -1,4 +1,4 @@ -import { MessageBar, MessageBarBody, Spinner } from "@fluentui/react-components"; +import { MessageBar, MessageBarBody, Spinner } from "@fluentui/react-components"; type DataStateProps = { isLoading: boolean; @@ -21,3 +21,4 @@ export function DataState({ isLoading, error }: DataStateProps) { return null; } + diff --git a/src/components/FormSection.tsx b/src/components/FormSection.tsx index 06905d9..4cbaeaa 100644 --- a/src/components/FormSection.tsx +++ b/src/components/FormSection.tsx @@ -1,4 +1,4 @@ -import { makeStyles, shorthands, tokens } from "@fluentui/react-components"; +import { makeStyles, shorthands, tokens } from "@fluentui/react-components"; import type { PropsWithChildren } from "react"; const useStyles = makeStyles({ @@ -58,3 +58,4 @@ export function FormActions({ children }: PropsWithChildren) { return
{children}
; } + diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx index f04b3b6..63a123b 100644 --- a/src/components/PageHeader.tsx +++ b/src/components/PageHeader.tsx @@ -1,4 +1,4 @@ -import { makeStyles, Text, Title1 } from "@fluentui/react-components"; +import { makeStyles, Text, Title1 } from "@fluentui/react-components"; const useStyles = makeStyles({ root: { @@ -23,3 +23,4 @@ export function PageHeader({ title, description }: PageHeaderProps) { ); } + diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index 8029476..0d17c66 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { Outlet, NavLink } from "react-router-dom"; +import { Outlet, NavLink } from "react-router-dom"; import { Button, makeStyles, @@ -71,6 +71,7 @@ const useStyles = makeStyles({ const links = [ { to: "/", label: "Dashboard", icon: }, { to: "/deployments", label: "Deployments", icon: }, + { to: "/deployment-rules", label: "Deployment Rules", icon: }, { to: "/worker-jobs", label: "Worker Jobs", icon: }, { to: "/domains", label: "Domains", icon: }, { to: "/environments", label: "Environments", icon: }, @@ -78,7 +79,7 @@ const links = [ { to: "/templates", label: "Templates", icon: }, { to: "/template-categories", label: "Template Categories", icon: }, { to: "/services", label: "Services", icon: }, - { to: "/virtual-machines", label: "Virtual Machines", icon: }, + { to: "/targets", label: "Targets", icon: }, ]; export function AppShell() { @@ -119,3 +120,4 @@ export function AppShell() { ); } + diff --git a/src/main.tsx b/src/main.tsx index 7a927a5..486d258 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React from "react"; import ReactDOM from "react-dom/client"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -9,6 +9,7 @@ import { DeploymentBatchDetailsPage } from "./pages/DeploymentBatchDetailsPage"; import { DeploymentJobDetailsPage } from "./pages/DeploymentJobDetailsPage"; import { DeploymentJobsPage } from "./pages/DeploymentJobsPage"; import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage"; +import { DeploymentRulesPage } from "./pages/DeploymentRulesPage"; import { DomainDetailsPage } from "./pages/DomainDetailsPage"; import { DomainsPage } from "./pages/DomainsPage"; import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage"; @@ -16,8 +17,8 @@ import { EnvironmentsPage } from "./pages/EnvironmentsPage"; import { TemplatesPage } from "./pages/TemplatesPage"; import { ServicesPage } from "./pages/ServicesPage"; import { TemplateCategoriesPage } from "./pages/TemplateCategoriesPage"; -import { VirtualMachineDetailsPage } from "./pages/VirtualMachineDetailsPage"; -import { VirtualMachinesPage } from "./pages/VirtualMachinesPage"; +import { TargetDetailsPage } from "./pages/TargetDetailsPage"; +import { TargetsPage } from "./pages/TargetsPage"; import "./styles/global.css"; const queryClient = new QueryClient({ @@ -36,6 +37,7 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "deployments", element: }, + { path: "deployment-rules", element: }, { path: "deployments/:id", element: }, { path: "worker-jobs", element: }, { path: "worker-jobs/:id", element: }, @@ -46,8 +48,8 @@ const router = createBrowserRouter([ { path: "templates", element: }, { path: "template-categories", element: }, { path: "services", element: }, - { path: "virtual-machines", element: }, - { path: "virtual-machines/:id", element: }, + { path: "targets", element: }, + { path: "targets/:id", element: }, ], }, ]); @@ -61,3 +63,4 @@ ReactDOM.createRoot(document.getElementById("root")!).render( , ); + diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index d2aeb86..ddcb6e7 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardHeader, @@ -78,9 +78,9 @@ export function DashboardPage() { queryKey: ["queue-jobs"], queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal), }); - const virtualMachines = useQuery({ - queryKey: ["virtual-machines"], - queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), + const targets = useQuery({ + queryKey: ["targets"], + queryFn: ({ signal }) => portalApi.getTargets(signal), }); const error = @@ -89,14 +89,14 @@ export function DashboardPage() { templates.error ?? services.error ?? queueJobs.error ?? - virtualMachines.error; + targets.error; const isLoading = domains.isLoading || environments.isLoading || templates.isLoading || services.isLoading || queueJobs.isLoading || - virtualMachines.isLoading; + targets.isLoading; const queueByStatus = (queueJobs.data ?? []).reduce>((acc, job) => { const key = job.status || "Unknown"; @@ -104,7 +104,7 @@ export function DashboardPage() { return acc; }, {}); - const vmCompliance = (virtualMachines.data ?? []).reduce( + const vmCompliance = (targets.data ?? []).reduce( (acc, vm) => { const compliance = getVmCompliance(vm.metadataJson); if (compliance === true) acc.compliant += 1; @@ -222,3 +222,4 @@ function getVmCompliance(metadataJson?: string): boolean | undefined { return undefined; } + diff --git a/src/pages/DeploymentBatchDetailsPage.tsx b/src/pages/DeploymentBatchDetailsPage.tsx index c327bb7..57dfa6b 100644 --- a/src/pages/DeploymentBatchDetailsPage.tsx +++ b/src/pages/DeploymentBatchDetailsPage.tsx @@ -1,8 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Checkbox, + Combobox, Field, + Input, + makeStyles, + Option, Table, TableBody, TableCell, @@ -10,109 +14,458 @@ import { TableHeaderCell, TableRow, Textarea, + Tooltip, } from "@fluentui/react-components"; -import { ArrowLeftRegular } from "@fluentui/react-icons"; +import { AddRegular, ArrowLeftRegular, DeleteRegular } from "@fluentui/react-icons"; import { useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { portalApi } from "../api/portalApi"; import { DataState } from "../components/DataState"; -import { FormActions, FormGrid, FormSection, FormWide } from "../components/FormSection"; import { PageHeader } from "../components/PageHeader"; +const useStyles = makeStyles({ + layout: { + display: "grid", + gap: "24px", + }, + section: { + display: "grid", + gap: "12px", + }, + sectionHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "12px", + }, + sectionTitle: { + fontSize: "18px", + fontWeight: 600, + }, + formGrid: { + display: "grid", + gridTemplateColumns: "repeat(4, minmax(160px, 1fr))", + gap: "12px", + }, + wide: { + gridColumn: "span 2", + }, + actions: { + display: "flex", + gap: "6px", + }, + monospace: { + fontFamily: "Consolas, monospace", + fontSize: "12px", + wordBreak: "break-word", + }, +}); + export function DeploymentBatchDetailsPage() { + const styles = useStyles(); const queryClient = useQueryClient(); const { id } = useParams(); - const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState([]); - const [jsonData, setJsonData] = useState("{}"); + + const [templateId, setTemplateId] = useState(""); + const [templateVersionId, setTemplateVersionId] = useState(""); + const [templateRole, setTemplateRole] = useState("Service"); + const [templateAlias, setTemplateAlias] = useState(""); + + const [parameterSelectionId, setParameterSelectionId] = useState(""); + const [parameterName, setParameterName] = useState(""); + const [parameterValueJson, setParameterValueJson] = useState('{"value":""}'); + const [parameterIsSecret, setParameterIsSecret] = useState(false); + + const [targetTargetId, setTargetTargetId] = useState(""); + const [targetRoleKey, setTargetRoleKey] = useState("Node"); + const [targetNodeDataJson, setTargetNodeDataJson] = useState("{}"); const { data: deploymentBatch, error, isLoading } = useQuery({ queryKey: ["deployment-batches", id], queryFn: ({ signal }) => portalApi.getDeploymentBatchById(id!, signal), enabled: Boolean(id), }); - const { data: virtualMachines } = useQuery({ - queryKey: ["virtual-machines"], - queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), + const { data: allDeployments } = useQuery({ + queryKey: ["deployments"], + queryFn: ({ signal }) => portalApi.getDeployments(signal), + }); + const { data: templates } = useQuery({ + queryKey: ["templates"], + queryFn: ({ signal }) => portalApi.getTemplates(signal), + }); + const { data: templateVersions } = useQuery({ + queryKey: ["template-versions", templateId], + queryFn: ({ signal }) => portalApi.getTemplateVersions(templateId, signal), + enabled: Boolean(templateId), + }); + const { data: targets } = useQuery({ + queryKey: ["targets"], + queryFn: ({ signal }) => portalApi.getTargets(signal), }); - const addDeploymentRequest = useMutation({ - mutationFn: portalApi.addDeploymentRequest, + const invalidateBatch = async () => { + await queryClient.invalidateQueries({ queryKey: ["deployment-batches", id] }); + await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] }); + }; + + const addTemplateSelection = useMutation({ + mutationFn: () => + portalApi.addDeploymentTemplateSelection(id!, { + templateVersionId, + templateRole, + alias: templateAlias || undefined, + }), onSuccess: async () => { - setSelectedVirtualMachineIds([]); - setJsonData("{}"); - await queryClient.invalidateQueries({ queryKey: ["deployments"] }); - await queryClient.invalidateQueries({ queryKey: ["deployment-batches", id] }); - await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); + setTemplateId(""); + setTemplateVersionId(""); + setTemplateRole("Service"); + setTemplateAlias(""); + await invalidateBatch(); }, }); - const filteredExecutions = useMemo(() => deploymentBatch?.deployments ?? [], [deploymentBatch?.deployments]); + const deleteTemplateSelection = useMutation({ + mutationFn: (selectionId: string) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId), + onSuccess: invalidateBatch, + }); + + const addParameterValue = useMutation({ + mutationFn: () => + portalApi.addDeploymentParameterValue(id!, { + deploymentTemplateSelectionId: parameterSelectionId || undefined, + name: parameterName, + valueJson: parameterValueJson, + isSecretReference: parameterIsSecret, + isOverride: true, + }), + onSuccess: async () => { + setParameterSelectionId(""); + setParameterName(""); + setParameterValueJson('{"value":""}'); + setParameterIsSecret(false); + await invalidateBatch(); + }, + }); + const deleteParameterValue = useMutation({ + mutationFn: (parameterValueId: string) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId), + onSuccess: invalidateBatch, + }); + + const addTargetAssignment = useMutation({ + mutationFn: () => + portalApi.addDeploymentTargetAssignment(id!, { + targetId: targetTargetId, + roleKey: targetRoleKey, + nodeDataJson: targetNodeDataJson, + }), + onSuccess: async () => { + setTargetTargetId(""); + setTargetRoleKey("Node"); + setTargetNodeDataJson("{}"); + await invalidateBatch(); + }, + }); + const deleteTargetAssignment = useMutation({ + mutationFn: (targetAssignmentId: string) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId), + onSuccess: invalidateBatch, + }); + + const filteredExecutions = useMemo(() => { + if (deploymentBatch?.deployments && deploymentBatch.deployments.length > 0) { + return deploymentBatch.deployments; + } + + return (allDeployments ?? []).filter( + (execution) => execution.deploymentGroupId === id || execution.deploymentBatchId === id, + ); + }, [allDeployments, deploymentBatch?.deployments, id]); + + const templateSelectionError = addTemplateSelection.error; + const parameterError = addParameterValue.error; + const targetError = addTargetAssignment.error; return ( - <> +
- - { - event.preventDefault(); - if (!id) return; - addDeploymentRequest.mutate({ deploymentBatchId: id, jsonData, virtualMachineIds: selectedVirtualMachineIds }); - }} - > - - -
- {(virtualMachines ?? []).map((virtualMachine) => ( - { - if (data.checked) { - setSelectedVirtualMachineIds((previous) => [...previous, virtualMachine.id]); - } else { - setSelectedVirtualMachineIds((previous) => previous.filter((vmId) => vmId !== virtualMachine.id)); - } - }} - /> - ))} -
-
- - -