diff --git a/src/api/portalApi.ts b/src/api/portalApi.ts index 40a3c59..e1d16ec 100644 --- a/src/api/portalApi.ts +++ b/src/api/portalApi.ts @@ -1,30 +1,53 @@ import { deleteJson, getJson, postJson, putJson } from "./httpClient"; import type { - AddDeployment, - AddDeploymentGroup, + AddDeploymentExecution, + AddDeploymentRequest, + AddDeploymentBatch, AddDomain, AddEnvironment, AddRunbook, AddService, + AddServiceRoleDefinition, + AddTemplateCategory, AddTemplate, - Deployment, - DeploymentGroup, + DeploymentExecution, + DeploymentBatch, + DeploymentBatchDetails, Domain, DomainWithEnvironments, + DomainWithVirtualMachines, EnvironmentItem, EnvironmentWithDomains, Runbook, ServiceItem, + ServiceRoleDefinition, + TemplateCategory, Template, + VirtualMachine, + AddVirtualMachine, + DeploymentJob, + DeploymentJobDetails, } from "../types/portal"; export const portalApi = { - getDeployments: (signal?: AbortSignal) => getJson("/Deployment", signal), - addDeployment: (deployment: AddDeployment) => postJson("/Deployment", deployment), - getDeploymentGroups: (signal?: AbortSignal) => getJson("/DeploymentGroup", signal), - addDeploymentGroup: (deploymentGroup: AddDeploymentGroup) => postJson("/DeploymentGroup", deploymentGroup), + getDeployments: (signal?: AbortSignal) => getJson("/Deployment", signal), + addDeployment: (deployment: AddDeploymentExecution) => postJson("/Deployment", deployment), + addDeploymentRequest: (request: AddDeploymentRequest) => postJson("/Deployment/Request", request), + getDeploymentJobs: (signal?: AbortSignal) => getJson("/Deployment/QueueJobs", signal), + getDeploymentJobById: (deploymentJobId: string, signal?: AbortSignal) => + getJson(`/Deployment/QueueJobs/${deploymentJobId}`, signal), + retryDeploymentJob: (deploymentJobId: string) => postJson(`/Deployment/QueueJobs/${deploymentJobId}/Retry`, undefined), + approveDeploymentJobStep: (stepId: string, comment?: string) => + postJson<{ comment?: string }, void>(`/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), + getDeploymentBatchById: (deploymentBatchId: string, signal?: AbortSignal) => + getJson(`/DeploymentGroup/${deploymentBatchId}`, signal), + addDeploymentBatch: (deploymentBatch: AddDeploymentBatch) => postJson("/DeploymentGroup", deploymentBatch), 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), addDomain: (domain: AddDomain) => postJson("/Domain", domain), updateDomain: (domainId: string, domain: AddDomain) => putJson(`/Domain/${domainId}`, domain), deleteDomain: (domainId: string) => deleteJson(`/Domain/${domainId}`), @@ -44,4 +67,26 @@ export const portalApi = { addService: (service: AddService) => postJson("/Service", service), updateService: (serviceId: string, service: AddService) => putJson(`/Service/${serviceId}`, service), deleteService: (serviceId: string) => deleteJson(`/Service/${serviceId}`), + getServiceRoleDefinitions: (serviceId: string, signal?: AbortSignal) => + getJson(`/Service/${serviceId}/RoleDefinitions`, signal), + addServiceRoleDefinition: (serviceId: string, roleDefinition: AddServiceRoleDefinition) => + postJson(`/Service/${serviceId}/RoleDefinitions`, roleDefinition), + updateServiceRoleDefinition: (serviceId: string, roleDefinitionId: string, roleDefinition: AddServiceRoleDefinition) => + putJson(`/Service/${serviceId}/RoleDefinitions/${roleDefinitionId}`, roleDefinition), + deleteServiceRoleDefinition: (serviceId: string, roleDefinitionId: string) => + deleteJson(`/Service/${serviceId}/RoleDefinitions/${roleDefinitionId}`), + getTemplateCategories: (signal?: AbortSignal) => getJson("/TemplateCategory", signal), + addTemplateCategory: (templateCategory: AddTemplateCategory) => postJson("/TemplateCategory", templateCategory), + 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`), }; diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index 1cea4ce..8029476 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -10,13 +10,11 @@ import { import { AppsListDetail24Regular, BoxMultiple24Regular, - CloudFlow24Regular, DatabasePlugConnectedRegular, - LinkMultiple24Regular, Globe24Regular, Home24Regular, - PlayCircle24Regular, - ServerMultipleRegular, + TagMultiple24Regular, + Desktop24Regular, } from "@fluentui/react-icons"; const useStyles = makeStyles({ @@ -72,14 +70,15 @@ const useStyles = makeStyles({ const links = [ { to: "/", label: "Dashboard", icon: }, - //{ to: "/deployments", label: "Deployments", icon: }, - //{ to: "/deployment-groups", label: "Deployment Groups", icon: }, + { to: "/deployments", label: "Deployments", icon: }, + { to: "/worker-jobs", label: "Worker Jobs", icon: }, { to: "/domains", label: "Domains", icon: }, - { to: "/environment-domains", label: "Environment Domains", icon: }, { to: "/environments", label: "Environments", icon: }, //{ to: "/runbooks", label: "Runbooks", icon: }, { to: "/templates", label: "Templates", icon: }, + { to: "/template-categories", label: "Template Categories", icon: }, { to: "/services", label: "Services", icon: }, + { to: "/virtual-machines", label: "Virtual Machines", icon: }, ]; export function AppShell() { diff --git a/src/main.tsx b/src/main.tsx index c71e3fa..7a927a5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,16 +5,19 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { AppShell } from "./layout/AppShell"; import { DashboardPage } from "./pages/DashboardPage"; -import { DeploymentsPage } from "./pages/DeploymentsPage"; +import { DeploymentBatchDetailsPage } from "./pages/DeploymentBatchDetailsPage"; +import { DeploymentJobDetailsPage } from "./pages/DeploymentJobDetailsPage"; +import { DeploymentJobsPage } from "./pages/DeploymentJobsPage"; import { DeploymentGroupsPage } from "./pages/DeploymentGroupsPage"; import { DomainDetailsPage } from "./pages/DomainDetailsPage"; import { DomainsPage } from "./pages/DomainsPage"; -import { EnvironmentDomainsPage } from "./pages/EnvironmentDomainsPage"; import { EnvironmentDetailsPage } from "./pages/EnvironmentDetailsPage"; import { EnvironmentsPage } from "./pages/EnvironmentsPage"; -import { RunbooksPage } from "./pages/RunbooksPage"; 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 "./styles/global.css"; const queryClient = new QueryClient({ @@ -32,16 +35,19 @@ const router = createBrowserRouter([ element: , children: [ { index: true, element: }, - { path: "deployments", element: }, - { path: "deployment-groups", element: }, + { path: "deployments", element: }, + { path: "deployments/:id", element: }, + { path: "worker-jobs", element: }, + { path: "worker-jobs/:id", element: }, { path: "domains", element: }, { path: "domains/:id", element: }, - { path: "environment-domains", element: }, { path: "environments", element: }, { path: "environments/:id", element: }, - { path: "runbooks", element: }, { path: "templates", element: }, + { path: "template-categories", element: }, { path: "services", element: }, + { path: "virtual-machines", element: }, + { path: "virtual-machines/:id", element: }, ], }, ]); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 7968a09..d2aeb86 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Card, CardHeader, + Divider, makeStyles, Text, tokens, @@ -16,11 +17,43 @@ const useStyles = makeStyles({ gridTemplateColumns: "repeat(4, minmax(160px, 1fr))", gap: "16px", }, + chartGrid: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(280px, 1fr))", + gap: "16px", + marginTop: "16px", + }, metric: { fontSize: "32px", fontWeight: tokens.fontWeightSemibold, lineHeight: "40px", }, + donutWrap: { + display: "flex", + alignItems: "center", + gap: "16px", + }, + donut: { + width: "120px", + height: "120px", + borderRadius: "50%", + flexShrink: 0, + }, + legend: { + display: "grid", + gap: "8px", + }, + legendRow: { + display: "flex", + alignItems: "center", + gap: "8px", + }, + swatch: { + width: "10px", + height: "10px", + borderRadius: "50%", + display: "inline-block", + }, }); export function DashboardPage() { @@ -41,10 +74,46 @@ export function DashboardPage() { queryKey: ["services"], queryFn: ({ signal }) => portalApi.getServices(signal), }); + const queueJobs = useQuery({ + queryKey: ["queue-jobs"], + queryFn: ({ signal }) => portalApi.getDeploymentJobs(signal), + }); + const virtualMachines = useQuery({ + queryKey: ["virtual-machines"], + queryFn: ({ signal }) => portalApi.getVirtualMachines(signal), + }); - const error = domains.error ?? environments.error ?? templates.error ?? services.error; + const error = + domains.error ?? + environments.error ?? + templates.error ?? + services.error ?? + queueJobs.error ?? + virtualMachines.error; const isLoading = - domains.isLoading || environments.isLoading || templates.isLoading || services.isLoading; + domains.isLoading || + environments.isLoading || + templates.isLoading || + services.isLoading || + queueJobs.isLoading || + virtualMachines.isLoading; + + const queueByStatus = (queueJobs.data ?? []).reduce>((acc, job) => { + const key = job.status || "Unknown"; + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}); + + const vmCompliance = (virtualMachines.data ?? []).reduce( + (acc, vm) => { + const compliance = getVmCompliance(vm.metadataJson); + if (compliance === true) acc.compliant += 1; + else if (compliance === false) acc.nonCompliant += 1; + else acc.unknown += 1; + return acc; + }, + { compliant: 0, nonCompliant: 0, unknown: 0 }, + ); return ( <> @@ -58,6 +127,26 @@ export function DashboardPage() { )} + {!isLoading && !error && ( +
+ ({ + label, + value, + color: chartColors[index % chartColors.length], + }))} + /> + +
+ )} ); } @@ -72,3 +161,64 @@ function MetricCard({ label, value }: { label: string; value: number }) { ); } + +const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"]; + +function DonutCard({ + title, + items, +}: { + title: string; + items: Array<{ label: string; value: number; color: string }>; +}) { + const styles = useStyles(); + const total = items.reduce((sum, item) => sum + item.value, 0); + const segments = total + ? items + .map((item) => `${item.color} ${(item.value / total) * 100}%`) + .join(", ") + : "#E1DFDD 100%"; + + return ( + + {title}} /> + +
+
+
+ {items.map((item) => ( +
+ + + {item.label}: {item.value} + +
+ ))} + Total: {total} +
+
+ + ); +} + +function getVmCompliance(metadataJson?: string): boolean | undefined { + if (!metadataJson) { + return undefined; + } + + try { + const parsed = JSON.parse(metadataJson) as Record; + const keys = ["dscCompliant", "isDscCompliant", "compliant"]; + + for (const key of keys) { + const value = parsed[key]; + if (typeof value === "boolean") { + return value; + } + } + } catch { + return undefined; + } + + return undefined; +} diff --git a/src/pages/DeploymentBatchDetailsPage.tsx b/src/pages/DeploymentBatchDetailsPage.tsx new file mode 100644 index 0000000..c327bb7 --- /dev/null +++ b/src/pages/DeploymentBatchDetailsPage.tsx @@ -0,0 +1,118 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Button, + Checkbox, + Field, + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + Textarea, +} from "@fluentui/react-components"; +import { ArrowLeftRegular } 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"; + +export function DeploymentBatchDetailsPage() { + const queryClient = useQueryClient(); + const { id } = useParams(); + const [selectedVirtualMachineIds, setSelectedVirtualMachineIds] = useState([]); + const [jsonData, setJsonData] = 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 addDeploymentRequest = useMutation({ + mutationFn: portalApi.addDeploymentRequest, + onSuccess: async () => { + setSelectedVirtualMachineIds([]); + setJsonData("{}"); + await queryClient.invalidateQueries({ queryKey: ["deployments"] }); + await queryClient.invalidateQueries({ queryKey: ["deployment-batches", id] }); + await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); + }, + }); + const filteredExecutions = useMemo(() => deploymentBatch?.deployments ?? [], [deploymentBatch?.deployments]); + + 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)); + } + }} + /> + ))} +
+
+ + +