diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index 0d17c66..c8876af 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -1,5 +1,6 @@ import { Outlet, NavLink } from "react-router-dom"; import { + Badge, Button, makeStyles, shorthands, @@ -16,6 +17,8 @@ import { TagMultiple24Regular, Desktop24Regular, } from "@fluentui/react-icons"; +import { useQuery } from "@tanstack/react-query"; +import { portalApi } from "../api/portalApi"; const useStyles = makeStyles({ root: { @@ -63,6 +66,11 @@ const useStyles = makeStyles({ ...shorthands.borderBottom("1px", "solid", tokens.colorNeutralStroke2), ...shorthands.padding("0", "28px"), }, + apiStatus: { + display: "flex", + alignItems: "center", + gap: "10px", + }, main: { ...shorthands.padding("28px"), }, @@ -84,6 +92,19 @@ const links = [ export function AppShell() { const styles = useStyles(); + const apiStatus = useQuery({ + queryKey: ["api-status"], + queryFn: ({ signal }) => portalApi.getServices(signal), + retry: 1, + staleTime: 60_000, + }); + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api"; + const apiBadge = + apiStatus.isLoading + ? { appearance: "tint" as const, color: "subtle" as const, label: "API pruefen" } + : apiStatus.error + ? { appearance: "filled" as const, color: "danger" as const, label: "API Fehler" } + : { appearance: "filled" as const, color: "success" as const, label: "API verbunden" }; return (
@@ -111,7 +132,12 @@ export function AppShell() {
Portal workspace - API: {import.meta.env.VITE_API_BASE_URL ?? "/api"} +
+ + {apiBadge.label} + + {apiBaseUrl} +
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index ddcb6e7..fea4c6a 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -4,9 +4,11 @@ import { CardHeader, Divider, makeStyles, + shorthands, Text, tokens, } from "@fluentui/react-components"; +import { Link } from "react-router-dom"; import { portalApi } from "../api/portalApi"; import { DataState } from "../components/DataState"; import { PageHeader } from "../components/PageHeader"; @@ -28,6 +30,28 @@ const useStyles = makeStyles({ fontWeight: tokens.fontWeightSemibold, lineHeight: "40px", }, + metricLink: { + color: "inherit", + display: "block", + textDecorationLine: "none", + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ":focus-visible": { + outlineStyle: "solid", + outlineWidth: "2px", + outlineColor: tokens.colorStrokeFocus2, + outlineOffset: "2px", + }, + }, + metricCard: { + transitionProperty: "box-shadow, transform, border-color", + transitionDuration: tokens.durationFaster, + transitionTimingFunction: tokens.curveEasyEase, + ":hover": { + boxShadow: tokens.shadow8, + transform: "translateY(-1px)", + ...shorthands.borderColor(tokens.colorNeutralStroke1Hover), + }, + }, donutWrap: { display: "flex", alignItems: "center", @@ -54,6 +78,12 @@ const useStyles = makeStyles({ borderRadius: "50%", display: "inline-block", }, + emptyState: { + minHeight: "120px", + display: "flex", + alignItems: "center", + color: tokens.colorNeutralForeground3, + }, }); export function DashboardPage() { @@ -121,16 +151,17 @@ export function DashboardPage() { {!isLoading && !error && (
- - - - + + + +
)} {!isLoading && !error && (
({ label, value, @@ -139,6 +170,7 @@ export function DashboardPage() { /> - {label}} /> - {value} - + + + {label}} /> + {value} + + ); } const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"]; function DonutCard({ + emptyText, title, items, }: { + emptyText: string; title: string; items: Array<{ label: string; value: number; color: string }>; }) { @@ -183,20 +219,26 @@ function DonutCard({ {title}} /> -
-
-
- {items.map((item) => ( -
- - - {item.label}: {item.value} - -
- ))} - Total: {total} + {total === 0 ? ( +
+ {emptyText}
-
+ ) : ( +
+
+
+ {items.map((item) => ( +
+ + + {item.label}: {item.value} + +
+ ))} + Total: {total} +
+
+ )} ); } diff --git a/src/pages/DeploymentBatchDetailsPage.tsx b/src/pages/DeploymentBatchDetailsPage.tsx index 57dfa6b..7a6ecb9 100644 --- a/src/pages/DeploymentBatchDetailsPage.tsx +++ b/src/pages/DeploymentBatchDetailsPage.tsx @@ -17,7 +17,7 @@ import { Tooltip, } from "@fluentui/react-components"; import { AddRegular, ArrowLeftRegular, DeleteRegular } from "@fluentui/react-icons"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type SetStateAction } from "react"; import { Link, useParams } from "react-router-dom"; import { portalApi } from "../api/portalApi"; import { DataState } from "../components/DataState"; @@ -42,6 +42,12 @@ const useStyles = makeStyles({ fontSize: "18px", fontWeight: 600, }, + headerActions: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "12px", + }, formGrid: { display: "grid", gridTemplateColumns: "repeat(4, minmax(160px, 1fr))", @@ -79,6 +85,9 @@ export function DeploymentBatchDetailsPage() { const [targetTargetId, setTargetTargetId] = useState(""); const [targetRoleKey, setTargetRoleKey] = useState("Node"); const [targetNodeDataJson, setTargetNodeDataJson] = useState("{}"); + const [selectedTemplateSelectionIds, setSelectedTemplateSelectionIds] = useState([]); + const [selectedParameterValueIds, setSelectedParameterValueIds] = useState([]); + const [selectedTargetAssignmentIds, setSelectedTargetAssignmentIds] = useState([]); const { data: deploymentBatch, error, isLoading } = useQuery({ queryKey: ["deployment-batches", id], @@ -127,6 +136,15 @@ export function DeploymentBatchDetailsPage() { mutationFn: (selectionId: string) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId), onSuccess: invalidateBatch, }); + const deleteSelectedTemplateSelections = useMutation({ + mutationFn: async (selectionIds: string[]) => { + await Promise.all(selectionIds.map((selectionId) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId))); + }, + onSuccess: async () => { + setSelectedTemplateSelectionIds([]); + await invalidateBatch(); + }, + }); const addParameterValue = useMutation({ mutationFn: () => @@ -149,6 +167,15 @@ export function DeploymentBatchDetailsPage() { mutationFn: (parameterValueId: string) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId), onSuccess: invalidateBatch, }); + const deleteSelectedParameterValues = useMutation({ + mutationFn: async (parameterValueIds: string[]) => { + await Promise.all(parameterValueIds.map((parameterValueId) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId))); + }, + onSuccess: async () => { + setSelectedParameterValueIds([]); + await invalidateBatch(); + }, + }); const addTargetAssignment = useMutation({ mutationFn: () => @@ -168,6 +195,29 @@ export function DeploymentBatchDetailsPage() { mutationFn: (targetAssignmentId: string) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId), onSuccess: invalidateBatch, }); + const deleteSelectedTargetAssignments = useMutation({ + mutationFn: async (targetAssignmentIds: string[]) => { + await Promise.all(targetAssignmentIds.map((targetAssignmentId) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId))); + }, + onSuccess: async () => { + setSelectedTargetAssignmentIds([]); + await invalidateBatch(); + }, + }); + const queueDeployment = useMutation({ + mutationFn: () => + portalApi.addDeploymentRequest({ + deploymentGroupId: id!, + targetIds: [], + jsonData: "{}", + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); + await queryClient.invalidateQueries({ queryKey: ["queue-jobs"] }); + await queryClient.invalidateQueries({ queryKey: ["deployments"] }); + await invalidateBatch(); + }, + }); const filteredExecutions = useMemo(() => { if (deploymentBatch?.deployments && deploymentBatch.deployments.length > 0) { @@ -182,18 +232,53 @@ export function DeploymentBatchDetailsPage() { const templateSelectionError = addTemplateSelection.error; const parameterError = addParameterValue.error; const targetError = addTargetAssignment.error; + const queueError = queueDeployment.error; + const templateSelections = deploymentBatch?.templateSelections ?? []; + const parameterValues = deploymentBatch?.parameterValues ?? []; + const targetAssignments = deploymentBatch?.targetAssignments ?? []; + const allTemplateSelectionsSelected = + templateSelections.length > 0 && templateSelections.every((selection) => selectedTemplateSelectionIds.includes(selection.id)); + const allParameterValuesSelected = + parameterValues.length > 0 && parameterValues.every((parameterValue) => selectedParameterValueIds.includes(parameterValue.id)); + const allTargetAssignmentsSelected = + targetAssignments.length > 0 && targetAssignments.every((targetAssignment) => selectedTargetAssignmentIds.includes(targetAssignment.id)); + const toggleSelected = (ids: string[], setIds: (value: SetStateAction) => void, id: string, checked: boolean) => { + setIds(checked ? [...new Set([...ids, id])] : ids.filter((entry) => entry !== id)); + }; + const canQueueDeployment = + Boolean(id) && + ((deploymentBatch?.targetAssignments?.length ?? 0) > 0 || filteredExecutions.some((execution) => Boolean(execution.targetId))); return (
- - + + - +
@@ -207,6 +292,18 @@ export function DeploymentBatchDetailsPage() { > Add +
@@ -249,6 +346,14 @@ export function DeploymentBatchDetailsPage() { + + + setSelectedTemplateSelectionIds(checkboxData.checked ? templateSelections.map((selection) => selection.id) : []) + } + /> + Order Role Alias @@ -259,8 +364,16 @@ export function DeploymentBatchDetailsPage() { - {(deploymentBatch?.templateSelections ?? []).map((selection) => ( + {templateSelections.map((selection) => ( + + + toggleSelected(selectedTemplateSelectionIds, setSelectedTemplateSelectionIds, selection.id, Boolean(checkboxData.checked)) + } + /> + {selection.sortOrder} {selection.templateRole} {selection.alias ?? "-"} @@ -295,6 +408,18 @@ export function DeploymentBatchDetailsPage() { > Add +
@@ -329,6 +454,14 @@ export function DeploymentBatchDetailsPage() {
+ + + setSelectedParameterValueIds(checkboxData.checked ? parameterValues.map((parameterValue) => parameterValue.id) : []) + } + /> + Scope Name Secret @@ -337,13 +470,21 @@ export function DeploymentBatchDetailsPage() { - {(deploymentBatch?.parameterValues ?? []).map((parameterValue) => { - const selection = (deploymentBatch?.templateSelections ?? []).find( + {parameterValues.map((parameterValue) => { + const selection = templateSelections.find( (entry) => entry.id === parameterValue.deploymentTemplateSelectionId, ); return ( + + + toggleSelected(selectedParameterValueIds, setSelectedParameterValueIds, parameterValue.id, Boolean(checkboxData.checked)) + } + /> + {selection?.alias ?? selection?.templateRole ?? "Global"} {parameterValue.name} {parameterValue.isSecretReference ? "Yes" : "No"} @@ -377,6 +518,18 @@ export function DeploymentBatchDetailsPage() { > Add +
@@ -402,6 +555,14 @@ export function DeploymentBatchDetailsPage() {
+ + + setSelectedTargetAssignmentIds(checkboxData.checked ? targetAssignments.map((targetAssignment) => targetAssignment.id) : []) + } + /> + Order Target Role @@ -410,8 +571,16 @@ export function DeploymentBatchDetailsPage() { - {(deploymentBatch?.targetAssignments ?? []).map((targetAssignment) => ( + {targetAssignments.map((targetAssignment) => ( + + + toggleSelected(selectedTargetAssignmentIds, setSelectedTargetAssignmentIds, targetAssignment.id, Boolean(checkboxData.checked)) + } + /> + {targetAssignment.sortOrder} {targetAssignment.target?.name ?? targetAssignment.targetId} {targetAssignment.roleKey} diff --git a/src/pages/DeploymentGroupsPage.tsx b/src/pages/DeploymentGroupsPage.tsx index d2a0a39..484f763 100644 --- a/src/pages/DeploymentGroupsPage.tsx +++ b/src/pages/DeploymentGroupsPage.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Checkbox, @@ -13,25 +13,72 @@ import { Input, makeStyles, Option, + shorthands, Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, + Text, + tokens, Tooltip, } from "@fluentui/react-components"; import { AddRegular, DeleteRegular, OpenRegular } from "@fluentui/react-icons"; -import { useMemo, useState } from "react"; +import { ChevronDownRegular, ChevronRightRegular } from "@fluentui/react-icons"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { portalApi } from "../api/portalApi"; import { DataState } from "../components/DataState"; import { PageHeader } from "../components/PageHeader"; +import type { DeploymentBatch } from "../types/portal"; + +const statusOrder = ["New", "Pending", "Running", "Failed", "Completed", "Unknown"]; const useStyles = makeStyles({ + pageActions: { + display: "flex", + gap: "10px", + marginBottom: "16px", + }, + groupRow: { + cursor: "pointer", + }, + groupHeader: { + display: "flex", + alignItems: "center", + gap: "8px", + }, + nestedLevel2Cell: { + paddingLeft: "48px", + }, actions: { display: "flex", - gap: "6px", + gap: "4px", + ...shorthands.padding("2px", "0"), + }, + muted: { + color: tokens.colorNeutralForeground3, + }, + idText: { + color: tokens.colorNeutralForeground3, + fontFamily: "Consolas, monospace", + fontSize: "12px", + }, + emptyState: { + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground3, + ...shorthands.border("1px", "solid", tokens.colorNeutralStroke2), + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ...shorthands.padding("24px"), + }, + dialogIntro: { + color: tokens.colorNeutralForeground3, + marginBottom: "16px", + }, + targetPicker: { + display: "grid", + gap: "10px", }, }); @@ -40,10 +87,15 @@ export function DeploymentGroupsPage() { const queryClient = useQueryClient(); const [serviceId, setServiceId] = useState(""); const [templateId, setTemplateId] = useState(""); + const [templateVersionId, setTemplateVersionId] = useState(""); const [deploymentRuleId, setDeploymentRuleId] = useState(""); const [targetIds, setTargetIds] = useState([]); const [targetSearch, setTargetSearch] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); + const [deleteCandidate, setDeleteCandidate] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [expandedStatusGroups, setExpandedStatusGroups] = useState>({}); + const { data, error, isLoading } = useQuery({ queryKey: ["deployment-batches"], queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal), @@ -64,17 +116,25 @@ export function DeploymentGroupsPage() { queryKey: ["targets"], queryFn: ({ signal }) => portalApi.getTargets(signal), }); + const { data: templateVersions } = useQuery({ + queryKey: ["template-versions", templateId], + queryFn: ({ signal }) => portalApi.getTemplateVersions(templateId, signal), + enabled: Boolean(templateId), + }); const { data: deploymentRules } = useQuery({ queryKey: ["deployment-rules"], queryFn: ({ signal }) => portalApi.getDeploymentRules(signal), }); + const addDeploymentBatch = useMutation({ mutationFn: portalApi.addDeploymentBatch, onSuccess: async () => { setServiceId(""); setTemplateId(""); + setTemplateVersionId(""); setDeploymentRuleId(""); setTargetIds([]); + setTargetSearch(""); setDialogOpen(false); await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] }); @@ -83,6 +143,17 @@ export function DeploymentGroupsPage() { const deleteDeploymentBatch = useMutation({ mutationFn: portalApi.deleteDeploymentBatch, onSuccess: async () => { + setDeleteCandidate(null); + await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] }); + await queryClient.invalidateQueries({ queryKey: ["deployments"] }); + }, + }); + const deleteSelectedDeploymentBatches = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteDeploymentBatch(id))); + }, + onSuccess: async () => { + setSelectedIds([]); await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] }); }, @@ -106,73 +177,184 @@ export function DeploymentGroupsPage() { filteredTargets.length > 0 && filteredTargets.every((target) => targetIds.includes(target.id)); + const groupedBatches = useMemo(() => { + const groups = (data ?? []).reduce>((acc, deploymentBatch) => { + const status = deploymentBatch.status || "Unknown"; + acc[status] = [...(acc[status] ?? []), deploymentBatch]; + return acc; + }, {}); + + return Object.entries(groups).sort(([left], [right]) => { + const leftIndex = statusOrder.indexOf(left); + const rightIndex = statusOrder.indexOf(right); + return (leftIndex === -1 ? statusOrder.length : leftIndex) - (rightIndex === -1 ? statusOrder.length : rightIndex); + }); + }, [data]); + const allSelected = Boolean(data?.length) && data!.every((deploymentBatch) => selectedIds.includes(deploymentBatch.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; + + useEffect(() => { + if (!templateVersions || templateVersions.length === 0 || templateVersionId) { + return; + } + + const publishedVersion = templateVersions.find((version) => version.isPublished); + setTemplateVersionId((publishedVersion ?? templateVersions[0]).id); + }, [templateVersionId, templateVersions]); + + const getServiceName = (deploymentBatch: DeploymentBatch) => { + const template = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId); + const category = (templateCategories ?? []).find((entry) => entry.id === template?.templateCategoryId); + const service = (services ?? []).find((entry) => entry.id === category?.serviceId); + return service?.name ?? "-"; + }; + + const getPrimaryTemplateLabel = (deploymentBatch: DeploymentBatch) => { + const fallbackTemplate = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId); + const templateName = deploymentBatch.primaryTemplateName ?? fallbackTemplate?.name ?? "-"; + const templateVersion = deploymentBatch.primaryTemplateVersion ? ` ${deploymentBatch.primaryTemplateVersion}` : ""; + const additionalTemplates = + deploymentBatch.templateSelectionCount > 1 ? ` +${deploymentBatch.templateSelectionCount - 1}` : ""; + return `${templateName}${templateVersion}${additionalTemplates}`; + }; + + const toggleStatusGroup = (status: string) => { + setExpandedStatusGroups((current) => ({ + ...current, + [status]: !current[status], + })); + }; + return ( <> - - - - {data && ( + +
+ + +
+ + {!isLoading && !error && groupedBatches.length === 0 && ( +
Keine Deployment Batches vorhanden.
+ )} + {!isLoading && !error && groupedBatches.length > 0 && (
- Service - Template - Rule - Status - Created - Modified - Id - Actions + + + setSelectedIds(checkboxData.checked ? (data ?? []).map((deploymentBatch) => deploymentBatch.id) : []) + } + /> + + + Service + + + Primary Template + + + Templates + + + Targets + + + Rule + + + Created + + + Batch + + + Actions + - {data.map((deploymentBatch) => ( - - - {(() => { - const template = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId); - const category = (templateCategories ?? []).find((entry) => entry.id === template?.templateCategoryId); - const service = (services ?? []).find((entry) => entry.id === category?.serviceId); - return service?.name ?? "-"; - })()} - - {(templates ?? []).find((entry) => entry.id === deploymentBatch.templateId)?.name ?? "-"} - - {(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"} - - {deploymentBatch.status ?? "Unknown"} - {deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"} - {deploymentBatch.modified ? new Date(deploymentBatch.modified).toLocaleString() : "-"} - {deploymentBatch.id} - -
- - -
-
-
+ {groupedBatches.map(([status, deploymentBatches]) => ( + + toggleStatusGroup(status)}> + +
+ {expandedStatusGroups[status] !== false ? : } + {status} +
+
+
+ {expandedStatusGroups[status] !== false && + deploymentBatches.map((deploymentBatch) => ( + + + toggleSelected(deploymentBatch.id, Boolean(checkboxData.checked))} + /> + + {getServiceName(deploymentBatch)} + {getPrimaryTemplateLabel(deploymentBatch)} + {deploymentBatch.templateSelectionCount ?? 0} + {deploymentBatch.targetAssignmentCount ?? 0} + + {(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"} + + {deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"} + + + {deploymentBatch.id.slice(0, 8)} + + + +
+ + +
+
+
+ ))} +
))}
)} + setDialogOpen(data.open)}> - Neues Deployment + Neuer Deployment Batch + + Erstellt eine neue Deployment Composition mit einer initialen Template Selection. + @@ -191,12 +374,15 @@ export function DeploymentGroupsPage() { ))} - + template.id === templateId)?.name ?? ""} - onOptionSelect={(_, data) => setTemplateId(data.optionValue ?? "")} + onOptionSelect={(_, data) => { + setTemplateId(data.optionValue ?? ""); + setTemplateVersionId(""); + }} > {(templates ?? []) .filter((template) => { @@ -210,6 +396,21 @@ export function DeploymentGroupsPage() { ))} + + version.id === templateVersionId)?.version ?? ""} + onOptionSelect={(_, data) => setTemplateVersionId(data.optionValue ?? "")} + > + {(templateVersions ?? []).map((version) => ( + + ))} + + {isOnPremService && ( - -
+ +
setTargetSearch(data.value)} /> @@ -240,17 +441,17 @@ export function DeploymentGroupsPage() { onChange={(_, data) => { if (data.checked) { setTargetIds((prev) => - Array.from(new Set([...prev, ...filteredTargets.map((vm) => vm.id)])), + Array.from(new Set([...prev, ...filteredTargets.map((target) => target.id)])), ); } else { - const visibleIds = new Set(filteredTargets.map((vm) => vm.id)); + const visibleIds = new Set(filteredTargets.map((target) => target.id)); setTargetIds((prev) => prev.filter((id) => !visibleIds.has(id))); } }} /> Name - Id + Provider @@ -269,7 +470,7 @@ export function DeploymentGroupsPage() { /> {target.name} - {target.id} + {target.providerType} ))} @@ -284,17 +485,44 @@ export function DeploymentGroupsPage() { + + + +
+ + !data.open && setDeleteCandidate(null)}> + + + Deployment Batch loeschen? + + + Der Batch {deleteCandidate?.id.slice(0, 8)} und seine Composition werden geloescht. + + + + + @@ -303,5 +531,3 @@ export function DeploymentGroupsPage() { ); } - - diff --git a/src/pages/DeploymentRulesPage.tsx b/src/pages/DeploymentRulesPage.tsx index eeb11ba..b7cd0d7 100644 --- a/src/pages/DeploymentRulesPage.tsx +++ b/src/pages/DeploymentRulesPage.tsx @@ -71,6 +71,11 @@ const useStyles = makeStyles({ addStepButton: { marginTop: "12px", }, + toolbar: { + display: "flex", + gap: "10px", + marginBottom: "16px", + }, }); function createDefaultStep(sortOrder: number): DeploymentRuleStep { @@ -92,6 +97,7 @@ export function DeploymentRulesPage() { const [description, setDescription] = useState(""); const [isActive, setIsActive] = useState(true); const [steps, setSteps] = useState([createDefaultStep(1)]); + const [selectedIds, setSelectedIds] = useState([]); const { data, error, isLoading } = useQuery({ queryKey: ["deployment-rules"], @@ -121,6 +127,15 @@ export function DeploymentRulesPage() { await queryClient.invalidateQueries({ queryKey: ["deployment-rules"] }); }, }); + const deleteSelectedRules = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteDeploymentRule(id))); + }, + onSuccess: async () => { + setSelectedIds([]); + await queryClient.invalidateQueries({ queryKey: ["deployment-rules"] }); + }, + }); const openAddDialog = () => { setDialogMode("add"); @@ -164,18 +179,42 @@ export function DeploymentRulesPage() { isActive, steps: normalizedSteps, }; + const allSelected = Boolean(data?.length) && data!.every((rule) => selectedIds.includes(rule.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; return ( <> - - +
+ + +
+ {data && ( + + setSelectedIds(checkboxData.checked ? data.map((rule) => rule.id) : [])} + /> + Name Status Steps @@ -186,6 +225,12 @@ export function DeploymentRulesPage() { {data.map((rule) => ( + + toggleSelected(rule.id, Boolean(checkboxData.checked))} + /> + {rule.name} {rule.isActive ? "Active" : "Inactive"} {rule.steps?.length ?? 0} diff --git a/src/pages/DeploymentsPage.tsx b/src/pages/DeploymentsPage.tsx index 12ed805..c2444aa 100644 --- a/src/pages/DeploymentsPage.tsx +++ b/src/pages/DeploymentsPage.tsx @@ -58,6 +58,8 @@ export function DeploymentsPage() { await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); }, }); + const selectedDeploymentBatch = (deploymentBatches ?? []).find((batch) => batch.id === deploymentBatchId); + const canUseCompositionTargets = Boolean(selectedDeploymentBatch && selectedDeploymentBatch.targetAssignmentCount > 0); return ( <> @@ -65,7 +67,7 @@ export function DeploymentsPage() { { event.preventDefault(); - addDeploymentRequest.mutate({ deploymentBatchId, jsonData, targetIds: selectedTargetIds }); + addDeploymentRequest.mutate({ deploymentGroupId: deploymentBatchId, jsonData, targetIds: selectedTargetIds }); }} > @@ -109,7 +111,7 @@ export function DeploymentsPage() { + !data.open && closeDialog()}> @@ -251,11 +278,20 @@ export function DomainsPage() { - + {data && (
+ + setSelectedIds(checkboxData.checked ? data.map((domain) => domain.id) : [])} + /> + Name FQDN NetBIOS @@ -266,6 +302,12 @@ export function DomainsPage() { {data.map((domain) => ( + + toggleSelected(domain.id, Boolean(checkboxData.checked))} + /> + {domain.name} {domain.fqdn} {domain.netBIOS} diff --git a/src/pages/EnvironmentsPage.tsx b/src/pages/EnvironmentsPage.tsx index e46834f..97be752 100644 --- a/src/pages/EnvironmentsPage.tsx +++ b/src/pages/EnvironmentsPage.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, + Checkbox, Combobox, Dialog, DialogActions, @@ -64,6 +65,7 @@ export function EnvironmentsPage() { const [metadataJson, setMetadataJson] = useState(""); const [linkEnvironmentId, setLinkEnvironmentId] = useState(""); const [domainId, setDomainId] = useState(""); + const [selectedIds, setSelectedIds] = useState([]); const { data, error, isLoading } = useQuery({ queryKey: ["environments"], queryFn: ({ signal }) => portalApi.getEnvironments(signal), @@ -144,6 +146,15 @@ export function EnvironmentsPage() { await queryClient.invalidateQueries({ queryKey: ["environments"] }); }, }); + const deleteSelectedEnvironments = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteEnvironment(id))); + }, + onSuccess: async () => { + setSelectedIds([]); + await queryClient.invalidateQueries({ queryKey: ["environments"] }); + }, + }); const linkDomainToEnvironment = useMutation({ mutationFn: ({ targetDomainId, environmentId }: { targetDomainId: string; environmentId: string }) => portalApi.linkDomainToEnvironment(targetDomainId, environmentId), @@ -180,6 +191,10 @@ export function EnvironmentsPage() { const isOnPrem = hostingType === "OnPrem"; const isAzureTenant = hostingType === "AzureTenant"; const isM365Tenant = hostingType === "M365Tenant"; + const allSelected = Boolean(data?.length) && data!.every((environment) => selectedIds.includes(environment.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; return ( <> @@ -191,6 +206,18 @@ export function EnvironmentsPage() { + !data.open && closeDialog()}> @@ -368,11 +395,22 @@ export function EnvironmentsPage() { - + {data && (
+ + + setSelectedIds(checkboxData.checked ? data.map((environment) => environment.id) : []) + } + /> + Name Stage Hosting @@ -387,6 +425,12 @@ export function EnvironmentsPage() { {data.map((environment) => ( + + toggleSelected(environment.id, Boolean(checkboxData.checked))} + /> + {environment.name} {environment.environmentType} {environment.hostingType} diff --git a/src/pages/ServicesPage.tsx b/src/pages/ServicesPage.tsx index bd9fe6a..82f6004 100644 --- a/src/pages/ServicesPage.tsx +++ b/src/pages/ServicesPage.tsx @@ -125,6 +125,7 @@ export function ServicesPage() { const [roleName, setRoleName] = useState(""); const [roleDescription, setRoleDescription] = useState(""); const [serviceFilter, setServiceFilter] = useState<"all" | "cloud" | "onprem">("all"); + const [selectedIds, setSelectedIds] = useState([]); const [expandedGroups, setExpandedGroups] = useState>({ onprem: true, cloud: true, @@ -192,6 +193,15 @@ export function ServicesPage() { await queryClient.invalidateQueries({ queryKey: ["services"] }); }, }); + const deleteSelectedServices = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteService(id))); + }, + onSuccess: async () => { + setSelectedIds([]); + await queryClient.invalidateQueries({ queryKey: ["services"] }); + }, + }); const addRoleDefinition = useMutation({ mutationFn: ({ serviceId, roleDefinition }: { serviceId: string; roleDefinition: AddServiceRoleDefinition }) => @@ -294,6 +304,11 @@ export function ServicesPage() { items: filteredServices.filter((service) => Boolean(service.isCloudService)), }, ].filter((group) => group.items.length > 0); + const allSelected = + filteredServices.length > 0 && filteredServices.every((service) => selectedIds.includes(service.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; const toggleGroup = (groupKey: string) => { setExpandedGroups((current) => ({ @@ -310,6 +325,18 @@ export function ServicesPage() { + - + {data && (
+ + + setSelectedIds(checkboxData.checked ? filteredServices.map((service) => service.id) : []) + } + /> + Name Description Id @@ -513,7 +548,7 @@ export function ServicesPage() { {groupedServices.map((group) => ( toggleGroup(group.key)}> - +
{expandedGroups[group.key] !== false ? : } {group.label} @@ -522,6 +557,12 @@ export function ServicesPage() { {expandedGroups[group.key] !== false && group.items.map((service) => ( + + toggleSelected(service.id, Boolean(checkboxData.checked))} + /> +
{getServiceIcon(service.iconKey)} diff --git a/src/pages/TargetsPage.tsx b/src/pages/TargetsPage.tsx index b9aea57..1dbd5b5 100644 --- a/src/pages/TargetsPage.tsx +++ b/src/pages/TargetsPage.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, + Checkbox, Combobox, Dialog, DialogActions, @@ -66,6 +67,7 @@ export function TargetsPage() { const [providerType, setProviderType] = useState("OnPrem"); const [externalId, setExternalId] = useState(""); const [metadataJson, setMetadataJson] = useState(""); + const [selectedIds, setSelectedIds] = useState([]); const { data, error, isLoading } = useQuery({ queryKey: ["targets"], @@ -132,6 +134,15 @@ export function TargetsPage() { await queryClient.invalidateQueries({ queryKey: ["targets"] }); }, }); + const deleteSelectedTargets = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteTarget(id))); + }, + onSuccess: async () => { + setSelectedIds([]); + await queryClient.invalidateQueries({ queryKey: ["targets"] }); + }, + }); const linkTargetToDomain = useMutation({ mutationFn: ({ targetId, targetDomainId }: { targetId: string; targetDomainId: string }) => @@ -172,6 +183,10 @@ export function TargetsPage() { const domainNameById = new Map((domains ?? []).map((domain: Domain) => [domain.id, domain.name])); const linkedTargets = (data ?? []).filter((target) => Boolean(target.domainID)); const unlinkedTargets = (data ?? []).filter((target) => !target.domainID); + const allSelected = Boolean(data?.length) && data!.every((target) => selectedIds.includes(target.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; return ( <> @@ -203,6 +218,19 @@ export function TargetsPage() { > Unlink Domain +
!dialogData.open && closeDialog()}> @@ -373,12 +401,18 @@ export function TargetsPage() { {data && (
+ + setSelectedIds(checkboxData.checked ? data.map((target) => target.id) : [])} + /> + Name Type Provider @@ -391,6 +425,12 @@ export function TargetsPage() { {data.map((target) => ( + + toggleSelected(target.id, Boolean(checkboxData.checked))} + /> + {target.name} {target.targetType} {target.providerType} diff --git a/src/pages/TemplateCategoriesPage.tsx b/src/pages/TemplateCategoriesPage.tsx index 1c196f6..531b334 100644 --- a/src/pages/TemplateCategoriesPage.tsx +++ b/src/pages/TemplateCategoriesPage.tsx @@ -108,6 +108,7 @@ export function TemplateCategoriesPage() { const [isActive, setIsActive] = useState(true); const [color, setColor] = useState(""); const [serviceFilterId, setServiceFilterId] = useState("all"); + const [selectedIds, setSelectedIds] = useState([]); const [expandedGroups, setExpandedGroups] = useState>({}); const { data, error, isLoading } = useQuery({ queryKey: ["template-categories"], @@ -176,6 +177,15 @@ export function TemplateCategoriesPage() { await queryClient.invalidateQueries({ queryKey: ["template-categories"] }); }, }); + const deleteSelectedTemplateCategories = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteTemplateCategory(id))); + }, + onSuccess: async () => { + setSelectedIds([]); + await queryClient.invalidateQueries({ queryKey: ["template-categories"] }); + }, + }); const submitTemplateCategory = (event: React.FormEvent) => { event.preventDefault(); @@ -212,6 +222,12 @@ export function TemplateCategoriesPage() { .sort((a, b) => a.name.localeCompare(b.name)), })) .filter((group) => group.items.length > 0); + const allSelected = + filteredTemplateCategories.length > 0 && + filteredTemplateCategories.every((templateCategory) => selectedIds.includes(templateCategory.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; const toggleGroup = (groupId: string) => { setExpandedGroups((current) => ({ @@ -228,6 +244,18 @@ export function TemplateCategoriesPage() { + - + {data && (
+ + + setSelectedIds( + checkboxData.checked ? filteredTemplateCategories.map((templateCategory) => templateCategory.id) : [], + ) + } + /> + Name Active Id @@ -335,7 +376,7 @@ export function TemplateCategoriesPage() { key={`group-${group.serviceId}`} onClick={() => toggleGroup(group.serviceId)} > - +
{expandedGroups[group.serviceId] !== false ? : } {group.serviceName} @@ -344,6 +385,12 @@ export function TemplateCategoriesPage() { {expandedGroups[group.serviceId] !== false && group.items.map((templateCategory) => ( + + toggleSelected(templateCategory.id, Boolean(checkboxData.checked))} + /> + {templateCategory.name} {templateCategory.isActive ? "Ja" : "Nein"} {templateCategory.id} diff --git a/src/pages/TemplatesPage.tsx b/src/pages/TemplatesPage.tsx index 7416c34..43cbe1c 100644 --- a/src/pages/TemplatesPage.tsx +++ b/src/pages/TemplatesPage.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, + Checkbox, Combobox, Dialog, DialogActions, @@ -161,6 +162,7 @@ export function TemplatesPage() { const [jsonResources, setJsonResources] = useState("[]"); const [editorTab, setEditorTab] = useState("parameters"); const [serviceFilterId, setServiceFilterId] = useState("all"); + const [selectedIds, setSelectedIds] = useState([]); const [expandedServiceGroups, setExpandedServiceGroups] = useState>({}); const [expandedCategoryGroups, setExpandedCategoryGroups] = useState>({}); const { data, error, isLoading } = useQuery({ @@ -255,6 +257,15 @@ export function TemplatesPage() { await queryClient.invalidateQueries({ queryKey: ["templates"] }); }, }); + const deleteSelectedTemplates = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => portalApi.deleteTemplate(id))); + }, + onSuccess: async () => { + setSelectedIds([]); + await queryClient.invalidateQueries({ queryKey: ["templates"] }); + }, + }); const submitTemplate = (event: React.FormEvent) => { event.preventDefault(); @@ -326,6 +337,11 @@ export function TemplatesPage() { .sort((a, b) => a.categoryName.localeCompare(b.categoryName)), })) .filter((group) => group.categories.length > 0); + const allSelected = + filteredTemplates.length > 0 && filteredTemplates.every((template) => selectedIds.includes(template.id)); + const toggleSelected = (id: string, checked: boolean) => { + setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id))); + }; const toggleServiceGroup = (serviceId: string) => { setExpandedServiceGroups((current) => ({ @@ -350,6 +366,18 @@ export function TemplatesPage() { +
{data && (
+ + + setSelectedIds(checkboxData.checked ? filteredTemplates.map((template) => template.id) : []) + } + /> + Name Version Template Category @@ -504,7 +540,7 @@ export function TemplatesPage() { {groupedTemplates.map((group) => ( toggleServiceGroup(group.serviceId)}> - +
{expandedServiceGroups[group.serviceId] !== false ? : } {group.serviceName} @@ -517,7 +553,7 @@ export function TemplatesPage() { className={styles.groupRow} onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)} > - +
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? ( @@ -531,6 +567,12 @@ export function TemplatesPage() { {expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false && categoryGroup.items.map((template) => ( + + toggleSelected(template.id, Boolean(checkboxData.checked))} + /> + {template.name} {template.version} diff --git a/src/types/portal.ts b/src/types/portal.ts index 41e19da..d6a5953 100644 --- a/src/types/portal.ts +++ b/src/types/portal.ts @@ -22,19 +22,24 @@ export type AddDeploymentExecution = { }; export type AddDeploymentRequest = { - deploymentBatchId: string; + deploymentGroupId: string; targetIds: string[]; jsonData: string; }; export type DeploymentJob = { id: string; + correlationId: string; type: string; status: string; + priority: number; + scheduledAt?: string; attempts: number; maxAttempts: number; started?: string; finished?: string; + heartbeatAt?: string; + workerName?: string; errorMessage?: string; targetCount: number; succeededTargetCount: number; @@ -48,7 +53,10 @@ export type DeploymentJobTarget = { templateId: string; status: string; attempts: number; + started?: string; + finished?: string; errorMessage?: string; + outputMetadataJson?: string; }; export type DeploymentJobStep = { @@ -59,6 +67,10 @@ export type DeploymentJobStep = { stepType: string; status: string; metadataJson?: string; + started?: string; + finished?: string; + outputMetadataJson?: string; + errorMessage?: string; approvedAt?: string; approvedBy?: string; approvalComment?: string; @@ -66,13 +78,20 @@ export type DeploymentJobStep = { export type DeploymentJobDetails = { id: string; + correlationId: string; type: string; status: string; payloadJson: string; + priority: number; + scheduledAt?: string; attempts: number; maxAttempts: number; started?: string; finished?: string; + heartbeatAt?: string; + workerName?: string; + lockedBy?: string; + lockedUntil?: string; errorMessage?: string; targets: DeploymentJobTarget[]; steps: DeploymentJobStep[]; @@ -83,6 +102,11 @@ export type DeploymentBatch = { templateId?: string; deploymentRuleId?: string; status?: string; + primaryTemplateVersionId?: string; + primaryTemplateName?: string; + primaryTemplateVersion?: string; + templateSelectionCount: number; + targetAssignmentCount: number; created?: string; createdBy?: string; modified?: string; @@ -97,10 +121,13 @@ export type DeploymentBatchDetails = DeploymentBatch & { }; export type AddDeploymentBatch = { - templateId: string; + templateId?: string; + templateVersionId?: string; deploymentRuleId?: string; status: string; targetIds?: string[]; + templateSelections?: AddDeploymentTemplateSelection[]; + targetAssignments?: AddDeploymentTargetAssignment[]; }; export type DeploymentRuleStep = {