feat: add multi-select functionality across various pages

- Implemented checkbox selection for multiple items in DeploymentRulesPage, DomainsPage, EnvironmentsPage, ServicesPage, TargetsPage, TemplateCategoriesPage, and TemplatesPage.
- Added delete functionality for selected items with confirmation prompts.
- Updated state management to handle selected IDs for batch operations.
- Enhanced UI with a toolbar for actions related to selected items.
- Adjusted API calls to accommodate changes in data structure, including deployment requests and job details.
This commit is contained in:
Torsten Brendgen
2026-07-09 23:49:10 +02:00
parent 3083e81452
commit 1894f740b0
13 changed files with 911 additions and 118 deletions

View File

@@ -1,5 +1,6 @@
import { Outlet, NavLink } from "react-router-dom"; import { Outlet, NavLink } from "react-router-dom";
import { import {
Badge,
Button, Button,
makeStyles, makeStyles,
shorthands, shorthands,
@@ -16,6 +17,8 @@ import {
TagMultiple24Regular, TagMultiple24Regular,
Desktop24Regular, Desktop24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { useQuery } from "@tanstack/react-query";
import { portalApi } from "../api/portalApi";
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -63,6 +66,11 @@ const useStyles = makeStyles({
...shorthands.borderBottom("1px", "solid", tokens.colorNeutralStroke2), ...shorthands.borderBottom("1px", "solid", tokens.colorNeutralStroke2),
...shorthands.padding("0", "28px"), ...shorthands.padding("0", "28px"),
}, },
apiStatus: {
display: "flex",
alignItems: "center",
gap: "10px",
},
main: { main: {
...shorthands.padding("28px"), ...shorthands.padding("28px"),
}, },
@@ -84,6 +92,19 @@ const links = [
export function AppShell() { export function AppShell() {
const styles = useStyles(); 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 ( return (
<div className={styles.root}> <div className={styles.root}>
@@ -111,7 +132,12 @@ export function AppShell() {
<section className={styles.content}> <section className={styles.content}>
<header className={styles.topbar}> <header className={styles.topbar}>
<Text weight="semibold">Portal workspace</Text> <Text weight="semibold">Portal workspace</Text>
<Text size={200}>API: {import.meta.env.VITE_API_BASE_URL ?? "/api"}</Text> <div className={styles.apiStatus}>
<Badge appearance={apiBadge.appearance} color={apiBadge.color}>
{apiBadge.label}
</Badge>
<Text size={200}>{apiBaseUrl}</Text>
</div>
</header> </header>
<main className={styles.main}> <main className={styles.main}>
<Outlet /> <Outlet />

View File

@@ -4,9 +4,11 @@ import {
CardHeader, CardHeader,
Divider, Divider,
makeStyles, makeStyles,
shorthands,
Text, Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
@@ -28,6 +30,28 @@ const useStyles = makeStyles({
fontWeight: tokens.fontWeightSemibold, fontWeight: tokens.fontWeightSemibold,
lineHeight: "40px", 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: { donutWrap: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -54,6 +78,12 @@ const useStyles = makeStyles({
borderRadius: "50%", borderRadius: "50%",
display: "inline-block", display: "inline-block",
}, },
emptyState: {
minHeight: "120px",
display: "flex",
alignItems: "center",
color: tokens.colorNeutralForeground3,
},
}); });
export function DashboardPage() { export function DashboardPage() {
@@ -121,16 +151,17 @@ export function DashboardPage() {
<DataState isLoading={isLoading} error={error} /> <DataState isLoading={isLoading} error={error} />
{!isLoading && !error && ( {!isLoading && !error && (
<div className={styles.grid}> <div className={styles.grid}>
<MetricCard label="Domains" value={domains.data?.length ?? 0} /> <MetricCard label="Domains" value={domains.data?.length ?? 0} to="/domains" />
<MetricCard label="Environments" value={environments.data?.length ?? 0} /> <MetricCard label="Environments" value={environments.data?.length ?? 0} to="/environments" />
<MetricCard label="Templates" value={templates.data?.length ?? 0} /> <MetricCard label="Templates" value={templates.data?.length ?? 0} to="/templates" />
<MetricCard label="Services" value={services.data?.length ?? 0} /> <MetricCard label="Services" value={services.data?.length ?? 0} to="/services" />
</div> </div>
)} )}
{!isLoading && !error && ( {!isLoading && !error && (
<div className={styles.chartGrid}> <div className={styles.chartGrid}>
<DonutCard <DonutCard
title="Jobs" title="Jobs"
emptyText="Keine Worker Jobs vorhanden."
items={Object.entries(queueByStatus).map(([label, value], index) => ({ items={Object.entries(queueByStatus).map(([label, value], index) => ({
label, label,
value, value,
@@ -139,6 +170,7 @@ export function DashboardPage() {
/> />
<DonutCard <DonutCard
title="VM DSC Compliance" title="VM DSC Compliance"
emptyText="Keine Compliance-Daten gemeldet."
items={[ items={[
{ label: "Compliant", value: vmCompliance.compliant, color: "#107C10" }, { label: "Compliant", value: vmCompliance.compliant, color: "#107C10" },
{ label: "Non-Compliant", value: vmCompliance.nonCompliant, color: "#D13438" }, { label: "Non-Compliant", value: vmCompliance.nonCompliant, color: "#D13438" },
@@ -151,23 +183,27 @@ export function DashboardPage() {
); );
} }
function MetricCard({ label, value }: { label: string; value: number }) { function MetricCard({ label, value, to }: { label: string; value: number; to: string }) {
const styles = useStyles(); const styles = useStyles();
return ( return (
<Card> <Link className={styles.metricLink} to={to}>
<CardHeader header={<Text weight="semibold">{label}</Text>} /> <Card className={styles.metricCard}>
<Text className={styles.metric}>{value}</Text> <CardHeader header={<Text weight="semibold">{label}</Text>} />
</Card> <Text className={styles.metric}>{value}</Text>
</Card>
</Link>
); );
} }
const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"]; const chartColors = ["#0F6CBD", "#107C10", "#D13438", "#8764B8", "#CA5010", "#605E5C"];
function DonutCard({ function DonutCard({
emptyText,
title, title,
items, items,
}: { }: {
emptyText: string;
title: string; title: string;
items: Array<{ label: string; value: number; color: string }>; items: Array<{ label: string; value: number; color: string }>;
}) { }) {
@@ -183,20 +219,26 @@ function DonutCard({
<Card> <Card>
<CardHeader header={<Text weight="semibold">{title}</Text>} /> <CardHeader header={<Text weight="semibold">{title}</Text>} />
<Divider /> <Divider />
<div className={styles.donutWrap}> {total === 0 ? (
<div className={styles.donut} style={{ background: `conic-gradient(${segments})` }} /> <div className={styles.emptyState}>
<div className={styles.legend}> <Text>{emptyText}</Text>
{items.map((item) => (
<div key={item.label} className={styles.legendRow}>
<span className={styles.swatch} style={{ backgroundColor: item.color }} />
<Text>
{item.label}: {item.value}
</Text>
</div>
))}
<Text>Total: {total}</Text>
</div> </div>
</div> ) : (
<div className={styles.donutWrap}>
<div className={styles.donut} style={{ background: `conic-gradient(${segments})` }} />
<div className={styles.legend}>
{items.map((item) => (
<div key={item.label} className={styles.legendRow}>
<span className={styles.swatch} style={{ backgroundColor: item.color }} />
<Text>
{item.label}: {item.value}
</Text>
</div>
))}
<Text>Total: {total}</Text>
</div>
</div>
)}
</Card> </Card>
); );
} }

View File

@@ -17,7 +17,7 @@ import {
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { AddRegular, ArrowLeftRegular, DeleteRegular } from "@fluentui/react-icons"; 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 { Link, useParams } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
@@ -42,6 +42,12 @@ const useStyles = makeStyles({
fontSize: "18px", fontSize: "18px",
fontWeight: 600, fontWeight: 600,
}, },
headerActions: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
},
formGrid: { formGrid: {
display: "grid", display: "grid",
gridTemplateColumns: "repeat(4, minmax(160px, 1fr))", gridTemplateColumns: "repeat(4, minmax(160px, 1fr))",
@@ -79,6 +85,9 @@ export function DeploymentBatchDetailsPage() {
const [targetTargetId, setTargetTargetId] = useState(""); const [targetTargetId, setTargetTargetId] = useState("");
const [targetRoleKey, setTargetRoleKey] = useState("Node"); const [targetRoleKey, setTargetRoleKey] = useState("Node");
const [targetNodeDataJson, setTargetNodeDataJson] = useState("{}"); const [targetNodeDataJson, setTargetNodeDataJson] = useState("{}");
const [selectedTemplateSelectionIds, setSelectedTemplateSelectionIds] = useState<string[]>([]);
const [selectedParameterValueIds, setSelectedParameterValueIds] = useState<string[]>([]);
const [selectedTargetAssignmentIds, setSelectedTargetAssignmentIds] = useState<string[]>([]);
const { data: deploymentBatch, error, isLoading } = useQuery({ const { data: deploymentBatch, error, isLoading } = useQuery({
queryKey: ["deployment-batches", id], queryKey: ["deployment-batches", id],
@@ -127,6 +136,15 @@ export function DeploymentBatchDetailsPage() {
mutationFn: (selectionId: string) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId), mutationFn: (selectionId: string) => portalApi.deleteDeploymentTemplateSelection(id!, selectionId),
onSuccess: invalidateBatch, 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({ const addParameterValue = useMutation({
mutationFn: () => mutationFn: () =>
@@ -149,6 +167,15 @@ export function DeploymentBatchDetailsPage() {
mutationFn: (parameterValueId: string) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId), mutationFn: (parameterValueId: string) => portalApi.deleteDeploymentParameterValue(id!, parameterValueId),
onSuccess: invalidateBatch, 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({ const addTargetAssignment = useMutation({
mutationFn: () => mutationFn: () =>
@@ -168,6 +195,29 @@ export function DeploymentBatchDetailsPage() {
mutationFn: (targetAssignmentId: string) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId), mutationFn: (targetAssignmentId: string) => portalApi.deleteDeploymentTargetAssignment(id!, targetAssignmentId),
onSuccess: invalidateBatch, 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(() => { const filteredExecutions = useMemo(() => {
if (deploymentBatch?.deployments && deploymentBatch.deployments.length > 0) { if (deploymentBatch?.deployments && deploymentBatch.deployments.length > 0) {
@@ -182,18 +232,53 @@ export function DeploymentBatchDetailsPage() {
const templateSelectionError = addTemplateSelection.error; const templateSelectionError = addTemplateSelection.error;
const parameterError = addParameterValue.error; const parameterError = addParameterValue.error;
const targetError = addTargetAssignment.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<string[]>) => 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 ( return (
<div className={styles.layout}> <div className={styles.layout}>
<Link to="/deployments"> <div className={styles.headerActions}>
<Button appearance="subtle" icon={<ArrowLeftRegular />}> <Link to="/deployments">
Deployments <Button appearance="subtle" icon={<ArrowLeftRegular />}>
Deployments
</Button>
</Link>
<Button
appearance="primary"
disabled={!canQueueDeployment || queueDeployment.isPending}
icon={<AddRegular />}
onClick={() => queueDeployment.mutate()}
>
Queue Deployment
</Button> </Button>
</Link> </div>
<PageHeader title={id ? `Deployment Batch ${id}` : "Deployment Batch"} description="Composition, Targets und Executions fuer diesen Batch." /> <PageHeader title={id ? `Deployment Batch ${id}` : "Deployment Batch"} description="Composition, Targets und Executions fuer diesen Batch." />
<DataState <DataState
isLoading={isLoading} isLoading={isLoading}
error={error ?? templateSelectionError ?? parameterError ?? targetError} error={
error ??
templateSelectionError ??
deleteSelectedTemplateSelections.error ??
parameterError ??
deleteSelectedParameterValues.error ??
targetError ??
deleteSelectedTargetAssignments.error ??
queueError
}
/> />
<section className={styles.section}> <section className={styles.section}>
@@ -207,6 +292,18 @@ export function DeploymentBatchDetailsPage() {
> >
Add Add
</Button> </Button>
<Button
appearance="secondary"
icon={<DeleteRegular />}
disabled={selectedTemplateSelectionIds.length === 0 || deleteSelectedTemplateSelections.isPending}
onClick={() => {
if (window.confirm(`${selectedTemplateSelectionIds.length} Template Selections wirklich loeschen?`)) {
deleteSelectedTemplateSelections.mutate(selectedTemplateSelectionIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<Field label="Template"> <Field label="Template">
@@ -249,6 +346,14 @@ export function DeploymentBatchDetailsPage() {
<Table aria-label="Template selections"> <Table aria-label="Template selections">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allTemplateSelectionsSelected}
onChange={(_, checkboxData) =>
setSelectedTemplateSelectionIds(checkboxData.checked ? templateSelections.map((selection) => selection.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>Order</TableHeaderCell> <TableHeaderCell>Order</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell> <TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>Alias</TableHeaderCell> <TableHeaderCell>Alias</TableHeaderCell>
@@ -259,8 +364,16 @@ export function DeploymentBatchDetailsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(deploymentBatch?.templateSelections ?? []).map((selection) => ( {templateSelections.map((selection) => (
<TableRow key={selection.id}> <TableRow key={selection.id}>
<TableCell>
<Checkbox
checked={selectedTemplateSelectionIds.includes(selection.id)}
onChange={(_, checkboxData) =>
toggleSelected(selectedTemplateSelectionIds, setSelectedTemplateSelectionIds, selection.id, Boolean(checkboxData.checked))
}
/>
</TableCell>
<TableCell>{selection.sortOrder}</TableCell> <TableCell>{selection.sortOrder}</TableCell>
<TableCell>{selection.templateRole}</TableCell> <TableCell>{selection.templateRole}</TableCell>
<TableCell>{selection.alias ?? "-"}</TableCell> <TableCell>{selection.alias ?? "-"}</TableCell>
@@ -295,6 +408,18 @@ export function DeploymentBatchDetailsPage() {
> >
Add Add
</Button> </Button>
<Button
appearance="secondary"
icon={<DeleteRegular />}
disabled={selectedParameterValueIds.length === 0 || deleteSelectedParameterValues.isPending}
onClick={() => {
if (window.confirm(`${selectedParameterValueIds.length} Parameter Values wirklich loeschen?`)) {
deleteSelectedParameterValues.mutate(selectedParameterValueIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<Field label="Scope"> <Field label="Scope">
@@ -329,6 +454,14 @@ export function DeploymentBatchDetailsPage() {
<Table aria-label="Parameter values"> <Table aria-label="Parameter values">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allParameterValuesSelected}
onChange={(_, checkboxData) =>
setSelectedParameterValueIds(checkboxData.checked ? parameterValues.map((parameterValue) => parameterValue.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>Scope</TableHeaderCell> <TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Secret</TableHeaderCell> <TableHeaderCell>Secret</TableHeaderCell>
@@ -337,13 +470,21 @@ export function DeploymentBatchDetailsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(deploymentBatch?.parameterValues ?? []).map((parameterValue) => { {parameterValues.map((parameterValue) => {
const selection = (deploymentBatch?.templateSelections ?? []).find( const selection = templateSelections.find(
(entry) => entry.id === parameterValue.deploymentTemplateSelectionId, (entry) => entry.id === parameterValue.deploymentTemplateSelectionId,
); );
return ( return (
<TableRow key={parameterValue.id}> <TableRow key={parameterValue.id}>
<TableCell>
<Checkbox
checked={selectedParameterValueIds.includes(parameterValue.id)}
onChange={(_, checkboxData) =>
toggleSelected(selectedParameterValueIds, setSelectedParameterValueIds, parameterValue.id, Boolean(checkboxData.checked))
}
/>
</TableCell>
<TableCell>{selection?.alias ?? selection?.templateRole ?? "Global"}</TableCell> <TableCell>{selection?.alias ?? selection?.templateRole ?? "Global"}</TableCell>
<TableCell>{parameterValue.name}</TableCell> <TableCell>{parameterValue.name}</TableCell>
<TableCell>{parameterValue.isSecretReference ? "Yes" : "No"}</TableCell> <TableCell>{parameterValue.isSecretReference ? "Yes" : "No"}</TableCell>
@@ -377,6 +518,18 @@ export function DeploymentBatchDetailsPage() {
> >
Add Add
</Button> </Button>
<Button
appearance="secondary"
icon={<DeleteRegular />}
disabled={selectedTargetAssignmentIds.length === 0 || deleteSelectedTargetAssignments.isPending}
onClick={() => {
if (window.confirm(`${selectedTargetAssignmentIds.length} Target Assignments wirklich loeschen?`)) {
deleteSelectedTargetAssignments.mutate(selectedTargetAssignmentIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<Field label="Target"> <Field label="Target">
@@ -402,6 +555,14 @@ export function DeploymentBatchDetailsPage() {
<Table aria-label="Target assignments"> <Table aria-label="Target assignments">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allTargetAssignmentsSelected}
onChange={(_, checkboxData) =>
setSelectedTargetAssignmentIds(checkboxData.checked ? targetAssignments.map((targetAssignment) => targetAssignment.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>Order</TableHeaderCell> <TableHeaderCell>Order</TableHeaderCell>
<TableHeaderCell>Target</TableHeaderCell> <TableHeaderCell>Target</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell> <TableHeaderCell>Role</TableHeaderCell>
@@ -410,8 +571,16 @@ export function DeploymentBatchDetailsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(deploymentBatch?.targetAssignments ?? []).map((targetAssignment) => ( {targetAssignments.map((targetAssignment) => (
<TableRow key={targetAssignment.id}> <TableRow key={targetAssignment.id}>
<TableCell>
<Checkbox
checked={selectedTargetAssignmentIds.includes(targetAssignment.id)}
onChange={(_, checkboxData) =>
toggleSelected(selectedTargetAssignmentIds, setSelectedTargetAssignmentIds, targetAssignment.id, Boolean(checkboxData.checked))
}
/>
</TableCell>
<TableCell>{targetAssignment.sortOrder}</TableCell> <TableCell>{targetAssignment.sortOrder}</TableCell>
<TableCell>{targetAssignment.target?.name ?? targetAssignment.targetId}</TableCell> <TableCell>{targetAssignment.target?.name ?? targetAssignment.targetId}</TableCell>
<TableCell>{targetAssignment.roleKey}</TableCell> <TableCell>{targetAssignment.roleKey}</TableCell>

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -13,25 +13,72 @@ import {
Input, Input,
makeStyles, makeStyles,
Option, Option,
shorthands,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHeader, TableHeader,
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Text,
tokens,
Tooltip, Tooltip,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { AddRegular, DeleteRegular, OpenRegular } from "@fluentui/react-icons"; 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 { Link } from "react-router-dom";
import { portalApi } from "../api/portalApi"; import { portalApi } from "../api/portalApi";
import { DataState } from "../components/DataState"; import { DataState } from "../components/DataState";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
import type { DeploymentBatch } from "../types/portal";
const statusOrder = ["New", "Pending", "Running", "Failed", "Completed", "Unknown"];
const useStyles = makeStyles({ const useStyles = makeStyles({
pageActions: {
display: "flex",
gap: "10px",
marginBottom: "16px",
},
groupRow: {
cursor: "pointer",
},
groupHeader: {
display: "flex",
alignItems: "center",
gap: "8px",
},
nestedLevel2Cell: {
paddingLeft: "48px",
},
actions: { actions: {
display: "flex", 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 queryClient = useQueryClient();
const [serviceId, setServiceId] = useState(""); const [serviceId, setServiceId] = useState("");
const [templateId, setTemplateId] = useState(""); const [templateId, setTemplateId] = useState("");
const [templateVersionId, setTemplateVersionId] = useState("");
const [deploymentRuleId, setDeploymentRuleId] = useState(""); const [deploymentRuleId, setDeploymentRuleId] = useState("");
const [targetIds, setTargetIds] = useState<string[]>([]); const [targetIds, setTargetIds] = useState<string[]>([]);
const [targetSearch, setTargetSearch] = useState(""); const [targetSearch, setTargetSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [deleteCandidate, setDeleteCandidate] = useState<DeploymentBatch | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expandedStatusGroups, setExpandedStatusGroups] = useState<Record<string, boolean>>({});
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["deployment-batches"], queryKey: ["deployment-batches"],
queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal), queryFn: ({ signal }) => portalApi.getDeploymentBatches(signal),
@@ -64,17 +116,25 @@ export function DeploymentGroupsPage() {
queryKey: ["targets"], queryKey: ["targets"],
queryFn: ({ signal }) => portalApi.getTargets(signal), 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({ const { data: deploymentRules } = useQuery({
queryKey: ["deployment-rules"], queryKey: ["deployment-rules"],
queryFn: ({ signal }) => portalApi.getDeploymentRules(signal), queryFn: ({ signal }) => portalApi.getDeploymentRules(signal),
}); });
const addDeploymentBatch = useMutation({ const addDeploymentBatch = useMutation({
mutationFn: portalApi.addDeploymentBatch, mutationFn: portalApi.addDeploymentBatch,
onSuccess: async () => { onSuccess: async () => {
setServiceId(""); setServiceId("");
setTemplateId(""); setTemplateId("");
setTemplateVersionId("");
setDeploymentRuleId(""); setDeploymentRuleId("");
setTargetIds([]); setTargetIds([]);
setTargetSearch("");
setDialogOpen(false); setDialogOpen(false);
await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] });
@@ -83,6 +143,17 @@ export function DeploymentGroupsPage() {
const deleteDeploymentBatch = useMutation({ const deleteDeploymentBatch = useMutation({
mutationFn: portalApi.deleteDeploymentBatch, mutationFn: portalApi.deleteDeploymentBatch,
onSuccess: async () => { 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: ["deployment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["deployments"] }); await queryClient.invalidateQueries({ queryKey: ["deployments"] });
}, },
@@ -106,73 +177,184 @@ export function DeploymentGroupsPage() {
filteredTargets.length > 0 && filteredTargets.length > 0 &&
filteredTargets.every((target) => targetIds.includes(target.id)); filteredTargets.every((target) => targetIds.includes(target.id));
const groupedBatches = useMemo(() => {
const groups = (data ?? []).reduce<Record<string, DeploymentBatch[]>>((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 ( return (
<> <>
<PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." /> <PageHeader title="Deployments" description="Deployment Batches und Compositionen nach Status." />
<Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}> <div className={styles.pageActions}>
Neues Deployment <Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}>
</Button> Neuer Deployment Batch
<DataState isLoading={isLoading} error={error} /> </Button>
{data && ( <Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedDeploymentBatches.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Deployment Batches wirklich loeschen?`)) {
deleteSelectedDeploymentBatches.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div>
<DataState isLoading={isLoading} error={error ?? deleteSelectedDeploymentBatches.error} />
{!isLoading && !error && groupedBatches.length === 0 && (
<div className={styles.emptyState}>Keine Deployment Batches vorhanden.</div>
)}
{!isLoading && !error && groupedBatches.length > 0 && (
<Table aria-label="Deployment batches"> <Table aria-label="Deployment batches">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>Service</TableHeaderCell> <TableHeaderCell>
<TableHeaderCell>Template</TableHeaderCell> <Checkbox
<TableHeaderCell>Rule</TableHeaderCell> checked={allSelected}
<TableHeaderCell>Status</TableHeaderCell> onChange={(_, checkboxData) =>
<TableHeaderCell>Created</TableHeaderCell> setSelectedIds(checkboxData.checked ? (data ?? []).map((deploymentBatch) => deploymentBatch.id) : [])
<TableHeaderCell>Modified</TableHeaderCell> }
<TableHeaderCell>Id</TableHeaderCell> />
<TableHeaderCell>Actions</TableHeaderCell> </TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Service</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Primary Template</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Templates</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Targets</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Rule</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Created</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Batch</Text>
</TableHeaderCell>
<TableHeaderCell>
<Text weight="semibold">Actions</Text>
</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.map((deploymentBatch) => ( {groupedBatches.map(([status, deploymentBatches]) => (
<TableRow key={deploymentBatch.id}> <Fragment key={`status-${status}`}>
<TableCell> <TableRow className={styles.groupRow} onClick={() => toggleStatusGroup(status)}>
{(() => { <TableCell colSpan={9}>
const template = (templates ?? []).find((entry) => entry.id === deploymentBatch.templateId); <div className={styles.groupHeader}>
const category = (templateCategories ?? []).find((entry) => entry.id === template?.templateCategoryId); {expandedStatusGroups[status] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
const service = (services ?? []).find((entry) => entry.id === category?.serviceId); <strong>{status}</strong>
return service?.name ?? "-"; </div>
})()} </TableCell>
</TableCell> </TableRow>
<TableCell>{(templates ?? []).find((entry) => entry.id === deploymentBatch.templateId)?.name ?? "-"}</TableCell> {expandedStatusGroups[status] !== false &&
<TableCell> deploymentBatches.map((deploymentBatch) => (
{(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"} <TableRow key={deploymentBatch.id}>
</TableCell> <TableCell>
<TableCell>{deploymentBatch.status ?? "Unknown"}</TableCell> <Checkbox
<TableCell>{deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}</TableCell> checked={selectedIds.includes(deploymentBatch.id)}
<TableCell>{deploymentBatch.modified ? new Date(deploymentBatch.modified).toLocaleString() : "-"}</TableCell> onChange={(_, checkboxData) => toggleSelected(deploymentBatch.id, Boolean(checkboxData.checked))}
<TableCell>{deploymentBatch.id}</TableCell> />
<TableCell> </TableCell>
<div className={styles.actions}> <TableCell className={styles.nestedLevel2Cell}>{getServiceName(deploymentBatch)}</TableCell>
<Tooltip content="Details" relationship="label"> <TableCell>{getPrimaryTemplateLabel(deploymentBatch)}</TableCell>
<Link to={`/deployments/${deploymentBatch.id}`}> <TableCell>{deploymentBatch.templateSelectionCount ?? 0}</TableCell>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} /> <TableCell>{deploymentBatch.targetAssignmentCount ?? 0}</TableCell>
</Link> <TableCell>
</Tooltip> {(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
<Tooltip content="Delete" relationship="label"> </TableCell>
<Button <TableCell>{deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}</TableCell>
appearance="subtle" <TableCell>
aria-label="Delete" <Tooltip content={deploymentBatch.id} relationship="label">
icon={<DeleteRegular />} <span className={styles.idText}>{deploymentBatch.id.slice(0, 8)}</span>
onClick={() => deleteDeploymentBatch.mutate(deploymentBatch.id)} </Tooltip>
/> </TableCell>
</Tooltip> <TableCell>
</div> <div className={styles.actions}>
</TableCell> <Tooltip content="Details" relationship="label">
</TableRow> <Link to={`/deployments/${deploymentBatch.id}`}>
<Button appearance="subtle" aria-label="Details" icon={<OpenRegular />} />
</Link>
</Tooltip>
<Tooltip content="Delete" relationship="label">
<Button
appearance="subtle"
aria-label="Delete"
icon={<DeleteRegular />}
onClick={() => setDeleteCandidate(deploymentBatch)}
/>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)} )}
<Dialog open={dialogOpen} onOpenChange={(_, data) => setDialogOpen(data.open)}> <Dialog open={dialogOpen} onOpenChange={(_, data) => setDialogOpen(data.open)}>
<DialogSurface> <DialogSurface>
<DialogBody> <DialogBody>
<DialogTitle>Neues Deployment</DialogTitle> <DialogTitle>Neuer Deployment Batch</DialogTitle>
<DialogContent> <DialogContent>
<Text className={styles.dialogIntro}>
Erstellt eine neue Deployment Composition mit einer initialen Template Selection.
</Text>
<Field label="Service" required> <Field label="Service" required>
<Combobox <Combobox
placeholder="Service waehlen" placeholder="Service waehlen"
@@ -181,6 +363,7 @@ export function DeploymentGroupsPage() {
const nextServiceId = data.optionValue ?? ""; const nextServiceId = data.optionValue ?? "";
setServiceId(nextServiceId); setServiceId(nextServiceId);
setTemplateId(""); setTemplateId("");
setTemplateVersionId("");
setTargetIds([]); setTargetIds([]);
}} }}
> >
@@ -191,12 +374,15 @@ export function DeploymentGroupsPage() {
))} ))}
</Combobox> </Combobox>
</Field> </Field>
<Field label="Template" required> <Field label="Initial Template" required>
<Combobox <Combobox
placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"} placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"}
disabled={!serviceId} disabled={!serviceId}
value={templates?.find((template) => template.id === templateId)?.name ?? ""} value={templates?.find((template) => template.id === templateId)?.name ?? ""}
onOptionSelect={(_, data) => setTemplateId(data.optionValue ?? "")} onOptionSelect={(_, data) => {
setTemplateId(data.optionValue ?? "");
setTemplateVersionId("");
}}
> >
{(templates ?? []) {(templates ?? [])
.filter((template) => { .filter((template) => {
@@ -210,6 +396,21 @@ export function DeploymentGroupsPage() {
))} ))}
</Combobox> </Combobox>
</Field> </Field>
<Field label="Version" required>
<Combobox
placeholder={templateId ? "Version waehlen" : "Erst Template waehlen"}
disabled={!templateId}
value={templateVersions?.find((version) => version.id === templateVersionId)?.version ?? ""}
onOptionSelect={(_, data) => setTemplateVersionId(data.optionValue ?? "")}
>
{(templateVersions ?? []).map((version) => (
<Option key={version.id} text={version.version} value={version.id}>
{version.version}
{version.isPublished ? " published" : ""}
</Option>
))}
</Combobox>
</Field>
<Field label="Deployment Rule"> <Field label="Deployment Rule">
<Combobox <Combobox
placeholder="Rule waehlen (optional)" placeholder="Rule waehlen (optional)"
@@ -224,10 +425,10 @@ export function DeploymentGroupsPage() {
</Combobox> </Combobox>
</Field> </Field>
{isOnPremService && ( {isOnPremService && (
<Field label="Targets" required> <Field label="Initial Targets" required>
<div> <div className={styles.targetPicker}>
<Input <Input
placeholder="VM suchen (Name oder Id)" placeholder="Target suchen (Name oder Id)"
value={targetSearch} value={targetSearch}
onChange={(_, data) => setTargetSearch(data.value)} onChange={(_, data) => setTargetSearch(data.value)}
/> />
@@ -240,17 +441,17 @@ export function DeploymentGroupsPage() {
onChange={(_, data) => { onChange={(_, data) => {
if (data.checked) { if (data.checked) {
setTargetIds((prev) => setTargetIds((prev) =>
Array.from(new Set([...prev, ...filteredTargets.map((vm) => vm.id)])), Array.from(new Set([...prev, ...filteredTargets.map((target) => target.id)])),
); );
} else { } 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))); setTargetIds((prev) => prev.filter((id) => !visibleIds.has(id)));
} }
}} }}
/> />
</TableHeaderCell> </TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Provider</TableHeaderCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -269,7 +470,7 @@ export function DeploymentGroupsPage() {
/> />
</TableCell> </TableCell>
<TableCell>{target.name}</TableCell> <TableCell>{target.name}</TableCell>
<TableCell>{target.id}</TableCell> <TableCell>{target.providerType}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -284,17 +485,44 @@ export function DeploymentGroupsPage() {
</Button> </Button>
<Button <Button
appearance="primary" appearance="primary"
disabled={!templateId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending} disabled={!templateId || !templateVersionId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending}
onClick={() => onClick={() =>
addDeploymentBatch.mutate({ addDeploymentBatch.mutate({
status: "New", status: "New",
templateId, templateId,
templateVersionId,
deploymentRuleId: deploymentRuleId || undefined, deploymentRuleId: deploymentRuleId || undefined,
targetIds: isOnPremService ? targetIds : undefined, targetIds: isOnPremService ? targetIds : undefined,
}) })
} }
> >
Deployment anlegen Batch anlegen
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
<Dialog open={Boolean(deleteCandidate)} onOpenChange={(_, data) => !data.open && setDeleteCandidate(null)}>
<DialogSurface>
<DialogBody>
<DialogTitle>Deployment Batch loeschen?</DialogTitle>
<DialogContent>
<Text>
Der Batch {deleteCandidate?.id.slice(0, 8)} und seine Composition werden geloescht.
</Text>
</DialogContent>
<DialogActions>
<Button appearance="secondary" onClick={() => setDeleteCandidate(null)}>
Abbrechen
</Button>
<Button
appearance="primary"
disabled={!deleteCandidate || deleteDeploymentBatch.isPending}
icon={<DeleteRegular />}
onClick={() => deleteCandidate && deleteDeploymentBatch.mutate(deleteCandidate.id)}
>
Loeschen
</Button> </Button>
</DialogActions> </DialogActions>
</DialogBody> </DialogBody>
@@ -303,5 +531,3 @@ export function DeploymentGroupsPage() {
</> </>
); );
} }

View File

@@ -71,6 +71,11 @@ const useStyles = makeStyles({
addStepButton: { addStepButton: {
marginTop: "12px", marginTop: "12px",
}, },
toolbar: {
display: "flex",
gap: "10px",
marginBottom: "16px",
},
}); });
function createDefaultStep(sortOrder: number): DeploymentRuleStep { function createDefaultStep(sortOrder: number): DeploymentRuleStep {
@@ -92,6 +97,7 @@ export function DeploymentRulesPage() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState(true); const [isActive, setIsActive] = useState(true);
const [steps, setSteps] = useState<DeploymentRuleStep[]>([createDefaultStep(1)]); const [steps, setSteps] = useState<DeploymentRuleStep[]>([createDefaultStep(1)]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["deployment-rules"], queryKey: ["deployment-rules"],
@@ -121,6 +127,15 @@ export function DeploymentRulesPage() {
await queryClient.invalidateQueries({ queryKey: ["deployment-rules"] }); 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 = () => { const openAddDialog = () => {
setDialogMode("add"); setDialogMode("add");
@@ -164,18 +179,42 @@ export function DeploymentRulesPage() {
isActive, isActive,
steps: normalizedSteps, 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 ( return (
<> <>
<PageHeader title="Deployment Rules" description="Low-Code Regeln fuer Deployment Pipelines gestalten." /> <PageHeader title="Deployment Rules" description="Low-Code Regeln fuer Deployment Pipelines gestalten." />
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <div className={styles.toolbar}>
Neue Rule <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
</Button> Neue Rule
<DataState isLoading={isLoading} error={error} /> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedRules.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Deployment Rules wirklich loeschen?`)) {
deleteSelectedRules.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div>
<DataState isLoading={isLoading} error={error ?? deleteRule.error ?? deleteSelectedRules.error} />
{data && ( {data && (
<Table aria-label="Deployment rules"> <Table aria-label="Deployment rules">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) => setSelectedIds(checkboxData.checked ? data.map((rule) => rule.id) : [])}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Steps</TableHeaderCell> <TableHeaderCell>Steps</TableHeaderCell>
@@ -186,6 +225,12 @@ export function DeploymentRulesPage() {
<TableBody> <TableBody>
{data.map((rule) => ( {data.map((rule) => (
<TableRow key={rule.id}> <TableRow key={rule.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(rule.id)}
onChange={(_, checkboxData) => toggleSelected(rule.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell>{rule.name}</TableCell> <TableCell>{rule.name}</TableCell>
<TableCell>{rule.isActive ? "Active" : "Inactive"}</TableCell> <TableCell>{rule.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell>{rule.steps?.length ?? 0}</TableCell> <TableCell>{rule.steps?.length ?? 0}</TableCell>

View File

@@ -58,6 +58,8 @@ export function DeploymentsPage() {
await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] }); await queryClient.invalidateQueries({ queryKey: ["deployment-jobs"] });
}, },
}); });
const selectedDeploymentBatch = (deploymentBatches ?? []).find((batch) => batch.id === deploymentBatchId);
const canUseCompositionTargets = Boolean(selectedDeploymentBatch && selectedDeploymentBatch.targetAssignmentCount > 0);
return ( return (
<> <>
@@ -65,7 +67,7 @@ export function DeploymentsPage() {
<FormSection <FormSection
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
addDeploymentRequest.mutate({ deploymentBatchId, jsonData, targetIds: selectedTargetIds }); addDeploymentRequest.mutate({ deploymentGroupId: deploymentBatchId, jsonData, targetIds: selectedTargetIds });
}} }}
> >
<FormGrid> <FormGrid>
@@ -109,7 +111,7 @@ export function DeploymentsPage() {
<FormActions> <FormActions>
<Button <Button
appearance="primary" appearance="primary"
disabled={!deploymentBatchId || selectedTargetIds.length === 0 || addDeploymentRequest.isPending} disabled={!deploymentBatchId || (!canUseCompositionTargets && selectedTargetIds.length === 0) || addDeploymentRequest.isPending}
type="submit" type="submit"
> >
Start deployment Start deployment

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox, Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
@@ -59,6 +60,7 @@ export function DomainsPage() {
const [netBIOS, setNetBIOS] = useState(""); const [netBIOS, setNetBIOS] = useState("");
const [linkDomainId, setLinkDomainId] = useState(""); const [linkDomainId, setLinkDomainId] = useState("");
const [environmentId, setEnvironmentId] = useState(""); const [environmentId, setEnvironmentId] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["domains"], queryKey: ["domains"],
queryFn: ({ signal }) => portalApi.getDomains(signal), queryFn: ({ signal }) => portalApi.getDomains(signal),
@@ -120,6 +122,15 @@ export function DomainsPage() {
await queryClient.invalidateQueries({ queryKey: ["domains"] }); await queryClient.invalidateQueries({ queryKey: ["domains"] });
}, },
}); });
const deleteSelectedDomains = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => portalApi.deleteDomain(id)));
},
onSuccess: async () => {
setSelectedIds([]);
await queryClient.invalidateQueries({ queryKey: ["domains"] });
},
});
const linkDomainToEnvironment = useMutation({ const linkDomainToEnvironment = useMutation({
mutationFn: ({ domainId, environmentId: targetEnvironmentId }: { domainId: string; environmentId: string }) => mutationFn: ({ domainId, environmentId: targetEnvironmentId }: { domainId: string; environmentId: string }) =>
portalApi.linkDomainToEnvironment(domainId, targetEnvironmentId), portalApi.linkDomainToEnvironment(domainId, targetEnvironmentId),
@@ -145,6 +156,10 @@ export function DomainsPage() {
const formError = addDomain.error?.message ?? updateDomain.error?.message; const formError = addDomain.error?.message ?? updateDomain.error?.message;
const isSaving = addDomain.isPending || updateDomain.isPending; const isSaving = addDomain.isPending || updateDomain.isPending;
const allSelected = Boolean(data?.length) && data!.every((domain) => selectedIds.includes(domain.id));
const toggleSelected = (id: string, checked: boolean) => {
setSelectedIds((current) => (checked ? [...new Set([...current, id])] : current.filter((entry) => entry !== id)));
};
return ( return (
<> <>
@@ -156,6 +171,18 @@ export function DomainsPage() {
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}> <Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
Link to Environment Link to Environment
</Button> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedDomains.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Domains wirklich loeschen?`)) {
deleteSelectedDomains.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
@@ -251,11 +278,20 @@ export function DomainsPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<DataState isLoading={isLoading || environmentsLoading} error={error ?? environmentsError ?? deleteDomain.error ?? linkDomainToEnvironment.error} /> <DataState
isLoading={isLoading || environmentsLoading}
error={error ?? environmentsError ?? deleteDomain.error ?? deleteSelectedDomains.error ?? linkDomainToEnvironment.error}
/>
{data && ( {data && (
<Table aria-label="Domains"> <Table aria-label="Domains">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) => setSelectedIds(checkboxData.checked ? data.map((domain) => domain.id) : [])}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>FQDN</TableHeaderCell> <TableHeaderCell>FQDN</TableHeaderCell>
<TableHeaderCell>NetBIOS</TableHeaderCell> <TableHeaderCell>NetBIOS</TableHeaderCell>
@@ -266,6 +302,12 @@ export function DomainsPage() {
<TableBody> <TableBody>
{data.map((domain) => ( {data.map((domain) => (
<TableRow key={domain.id}> <TableRow key={domain.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(domain.id)}
onChange={(_, checkboxData) => toggleSelected(domain.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell>{domain.name}</TableCell> <TableCell>{domain.name}</TableCell>
<TableCell>{domain.fqdn}</TableCell> <TableCell>{domain.fqdn}</TableCell>
<TableCell>{domain.netBIOS}</TableCell> <TableCell>{domain.netBIOS}</TableCell>

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox, Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
@@ -64,6 +65,7 @@ export function EnvironmentsPage() {
const [metadataJson, setMetadataJson] = useState(""); const [metadataJson, setMetadataJson] = useState("");
const [linkEnvironmentId, setLinkEnvironmentId] = useState(""); const [linkEnvironmentId, setLinkEnvironmentId] = useState("");
const [domainId, setDomainId] = useState(""); const [domainId, setDomainId] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["environments"], queryKey: ["environments"],
queryFn: ({ signal }) => portalApi.getEnvironments(signal), queryFn: ({ signal }) => portalApi.getEnvironments(signal),
@@ -144,6 +146,15 @@ export function EnvironmentsPage() {
await queryClient.invalidateQueries({ queryKey: ["environments"] }); 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({ const linkDomainToEnvironment = useMutation({
mutationFn: ({ targetDomainId, environmentId }: { targetDomainId: string; environmentId: string }) => mutationFn: ({ targetDomainId, environmentId }: { targetDomainId: string; environmentId: string }) =>
portalApi.linkDomainToEnvironment(targetDomainId, environmentId), portalApi.linkDomainToEnvironment(targetDomainId, environmentId),
@@ -180,6 +191,10 @@ export function EnvironmentsPage() {
const isOnPrem = hostingType === "OnPrem"; const isOnPrem = hostingType === "OnPrem";
const isAzureTenant = hostingType === "AzureTenant"; const isAzureTenant = hostingType === "AzureTenant";
const isM365Tenant = hostingType === "M365Tenant"; 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 ( return (
<> <>
@@ -191,6 +206,18 @@ export function EnvironmentsPage() {
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}> <Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
Link to Domain Link to Domain
</Button> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedEnvironments.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Environments wirklich loeschen?`)) {
deleteSelectedEnvironments.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
@@ -368,11 +395,22 @@ export function EnvironmentsPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<DataState isLoading={isLoading || domainsLoading} error={error ?? domainsError ?? deleteEnvironment.error ?? linkDomainToEnvironment.error} /> <DataState
isLoading={isLoading || domainsLoading}
error={error ?? domainsError ?? deleteEnvironment.error ?? deleteSelectedEnvironments.error ?? linkDomainToEnvironment.error}
/>
{data && ( {data && (
<Table aria-label="Environments"> <Table aria-label="Environments">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) =>
setSelectedIds(checkboxData.checked ? data.map((environment) => environment.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Stage</TableHeaderCell> <TableHeaderCell>Stage</TableHeaderCell>
<TableHeaderCell>Hosting</TableHeaderCell> <TableHeaderCell>Hosting</TableHeaderCell>
@@ -387,6 +425,12 @@ export function EnvironmentsPage() {
<TableBody> <TableBody>
{data.map((environment) => ( {data.map((environment) => (
<TableRow key={environment.id}> <TableRow key={environment.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(environment.id)}
onChange={(_, checkboxData) => toggleSelected(environment.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell>{environment.name}</TableCell> <TableCell>{environment.name}</TableCell>
<TableCell>{environment.environmentType}</TableCell> <TableCell>{environment.environmentType}</TableCell>
<TableCell>{environment.hostingType}</TableCell> <TableCell>{environment.hostingType}</TableCell>

View File

@@ -125,6 +125,7 @@ export function ServicesPage() {
const [roleName, setRoleName] = useState(""); const [roleName, setRoleName] = useState("");
const [roleDescription, setRoleDescription] = useState(""); const [roleDescription, setRoleDescription] = useState("");
const [serviceFilter, setServiceFilter] = useState<"all" | "cloud" | "onprem">("all"); const [serviceFilter, setServiceFilter] = useState<"all" | "cloud" | "onprem">("all");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({ const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
onprem: true, onprem: true,
cloud: true, cloud: true,
@@ -192,6 +193,15 @@ export function ServicesPage() {
await queryClient.invalidateQueries({ queryKey: ["services"] }); 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({ const addRoleDefinition = useMutation({
mutationFn: ({ serviceId, roleDefinition }: { serviceId: string; roleDefinition: AddServiceRoleDefinition }) => mutationFn: ({ serviceId, roleDefinition }: { serviceId: string; roleDefinition: AddServiceRoleDefinition }) =>
@@ -294,6 +304,11 @@ export function ServicesPage() {
items: filteredServices.filter((service) => Boolean(service.isCloudService)), items: filteredServices.filter((service) => Boolean(service.isCloudService)),
}, },
].filter((group) => group.items.length > 0); ].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) => { const toggleGroup = (groupKey: string) => {
setExpandedGroups((current) => ({ setExpandedGroups((current) => ({
@@ -310,6 +325,18 @@ export function ServicesPage() {
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Service hinzufuegen Service hinzufuegen
</Button> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedServices.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Services wirklich loeschen?`)) {
deleteSelectedServices.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<Field className={styles.filter} label="Service Filter"> <Field className={styles.filter} label="Service Filter">
<Combobox <Combobox
@@ -498,11 +525,19 @@ export function ServicesPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<DataState isLoading={isLoading} error={error ?? deleteService.error} /> <DataState isLoading={isLoading} error={error ?? deleteService.error ?? deleteSelectedServices.error} />
{data && ( {data && (
<Table aria-label="Services"> <Table aria-label="Services">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) =>
setSelectedIds(checkboxData.checked ? filteredServices.map((service) => service.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell> <TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
@@ -513,7 +548,7 @@ export function ServicesPage() {
{groupedServices.map((group) => ( {groupedServices.map((group) => (
<Fragment key={group.key}> <Fragment key={group.key}>
<TableRow className={styles.groupRow} onClick={() => toggleGroup(group.key)}> <TableRow className={styles.groupRow} onClick={() => toggleGroup(group.key)}>
<TableCell colSpan={4}> <TableCell colSpan={5}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
{expandedGroups[group.key] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />} {expandedGroups[group.key] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>{group.label}</strong> <strong>{group.label}</strong>
@@ -522,6 +557,12 @@ export function ServicesPage() {
</TableRow> </TableRow>
{expandedGroups[group.key] !== false && group.items.map((service) => ( {expandedGroups[group.key] !== false && group.items.map((service) => (
<TableRow key={service.id}> <TableRow key={service.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(service.id)}
onChange={(_, checkboxData) => toggleSelected(service.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell className={styles.nestedCell}> <TableCell className={styles.nestedCell}>
<div className={styles.nameWithIcon}> <div className={styles.nameWithIcon}>
{getServiceIcon(service.iconKey)} {getServiceIcon(service.iconKey)}

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox, Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
@@ -66,6 +67,7 @@ export function TargetsPage() {
const [providerType, setProviderType] = useState("OnPrem"); const [providerType, setProviderType] = useState("OnPrem");
const [externalId, setExternalId] = useState(""); const [externalId, setExternalId] = useState("");
const [metadataJson, setMetadataJson] = useState(""); const [metadataJson, setMetadataJson] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["targets"], queryKey: ["targets"],
@@ -132,6 +134,15 @@ export function TargetsPage() {
await queryClient.invalidateQueries({ queryKey: ["targets"] }); 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({ const linkTargetToDomain = useMutation({
mutationFn: ({ targetId, targetDomainId }: { targetId: string; targetDomainId: string }) => 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 domainNameById = new Map((domains ?? []).map((domain: Domain) => [domain.id, domain.name]));
const linkedTargets = (data ?? []).filter((target) => Boolean(target.domainID)); const linkedTargets = (data ?? []).filter((target) => Boolean(target.domainID));
const unlinkedTargets = (data ?? []).filter((target) => !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 ( return (
<> <>
@@ -203,6 +218,19 @@ export function TargetsPage() {
> >
Unlink Domain Unlink Domain
</Button> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedTargets.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Targets wirklich loeschen?`)) {
deleteSelectedTargets.mutate(selectedIds);
}
}}
style={{ marginLeft: "10px" }}
>
Auswahl loeschen
</Button>
</div> </div>
<Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}> <Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}>
@@ -373,12 +401,18 @@ export function TargetsPage() {
<DataState <DataState
isLoading={isLoading || domainsLoading} isLoading={isLoading || domainsLoading}
error={error ?? domainsError ?? deleteTarget.error ?? linkTargetToDomain.error ?? unlinkTargetFromDomain.error} error={error ?? domainsError ?? deleteTarget.error ?? deleteSelectedTargets.error ?? linkTargetToDomain.error ?? unlinkTargetFromDomain.error}
/> />
{data && ( {data && (
<Table aria-label="Targets"> <Table aria-label="Targets">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) => setSelectedIds(checkboxData.checked ? data.map((target) => target.id) : [])}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell> <TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Provider</TableHeaderCell> <TableHeaderCell>Provider</TableHeaderCell>
@@ -391,6 +425,12 @@ export function TargetsPage() {
<TableBody> <TableBody>
{data.map((target) => ( {data.map((target) => (
<TableRow key={target.id}> <TableRow key={target.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(target.id)}
onChange={(_, checkboxData) => toggleSelected(target.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell>{target.name}</TableCell> <TableCell>{target.name}</TableCell>
<TableCell>{target.targetType}</TableCell> <TableCell>{target.targetType}</TableCell>
<TableCell>{target.providerType}</TableCell> <TableCell>{target.providerType}</TableCell>

View File

@@ -108,6 +108,7 @@ export function TemplateCategoriesPage() {
const [isActive, setIsActive] = useState(true); const [isActive, setIsActive] = useState(true);
const [color, setColor] = useState(""); const [color, setColor] = useState("");
const [serviceFilterId, setServiceFilterId] = useState("all"); const [serviceFilterId, setServiceFilterId] = useState("all");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({}); const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
queryKey: ["template-categories"], queryKey: ["template-categories"],
@@ -176,6 +177,15 @@ export function TemplateCategoriesPage() {
await queryClient.invalidateQueries({ queryKey: ["template-categories"] }); 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<HTMLFormElement>) => { const submitTemplateCategory = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -212,6 +222,12 @@ export function TemplateCategoriesPage() {
.sort((a, b) => a.name.localeCompare(b.name)), .sort((a, b) => a.name.localeCompare(b.name)),
})) }))
.filter((group) => group.items.length > 0); .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) => { const toggleGroup = (groupId: string) => {
setExpandedGroups((current) => ({ setExpandedGroups((current) => ({
@@ -228,6 +244,18 @@ export function TemplateCategoriesPage() {
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Template Category hinzufuegen Template Category hinzufuegen
</Button> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedTemplateCategories.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Template Categories wirklich loeschen?`)) {
deleteSelectedTemplateCategories.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<Field className={styles.filter} label="Service Filter"> <Field className={styles.filter} label="Service Filter">
<Combobox <Combobox
@@ -316,11 +344,24 @@ export function TemplateCategoriesPage() {
</DialogSurface> </DialogSurface>
</Dialog> </Dialog>
<DataState isLoading={isLoading || servicesLoading} error={error ?? servicesError ?? deleteTemplateCategory.error} /> <DataState
isLoading={isLoading || servicesLoading}
error={error ?? servicesError ?? deleteTemplateCategory.error ?? deleteSelectedTemplateCategories.error}
/>
{data && ( {data && (
<Table aria-label="Template Categories"> <Table aria-label="Template Categories">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) =>
setSelectedIds(
checkboxData.checked ? filteredTemplateCategories.map((templateCategory) => templateCategory.id) : [],
)
}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Active</TableHeaderCell> <TableHeaderCell>Active</TableHeaderCell>
<TableHeaderCell>Id</TableHeaderCell> <TableHeaderCell>Id</TableHeaderCell>
@@ -335,7 +376,7 @@ export function TemplateCategoriesPage() {
key={`group-${group.serviceId}`} key={`group-${group.serviceId}`}
onClick={() => toggleGroup(group.serviceId)} onClick={() => toggleGroup(group.serviceId)}
> >
<TableCell colSpan={4}> <TableCell colSpan={5}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
{expandedGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />} {expandedGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>{group.serviceName}</strong> <strong>{group.serviceName}</strong>
@@ -344,6 +385,12 @@ export function TemplateCategoriesPage() {
</TableRow> </TableRow>
{expandedGroups[group.serviceId] !== false && group.items.map((templateCategory) => ( {expandedGroups[group.serviceId] !== false && group.items.map((templateCategory) => (
<TableRow key={templateCategory.id}> <TableRow key={templateCategory.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(templateCategory.id)}
onChange={(_, checkboxData) => toggleSelected(templateCategory.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell className={styles.nestedCell}>{templateCategory.name}</TableCell> <TableCell className={styles.nestedCell}>{templateCategory.name}</TableCell>
<TableCell>{templateCategory.isActive ? "Ja" : "Nein"}</TableCell> <TableCell>{templateCategory.isActive ? "Ja" : "Nein"}</TableCell>
<TableCell>{templateCategory.id}</TableCell> <TableCell>{templateCategory.id}</TableCell>

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Checkbox,
Combobox, Combobox,
Dialog, Dialog,
DialogActions, DialogActions,
@@ -161,6 +162,7 @@ export function TemplatesPage() {
const [jsonResources, setJsonResources] = useState("[]"); const [jsonResources, setJsonResources] = useState("[]");
const [editorTab, setEditorTab] = useState<TemplateEditorTab>("parameters"); const [editorTab, setEditorTab] = useState<TemplateEditorTab>("parameters");
const [serviceFilterId, setServiceFilterId] = useState("all"); const [serviceFilterId, setServiceFilterId] = useState("all");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({}); const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({});
const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({}); const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({});
const { data, error, isLoading } = useQuery({ const { data, error, isLoading } = useQuery({
@@ -255,6 +257,15 @@ export function TemplatesPage() {
await queryClient.invalidateQueries({ queryKey: ["templates"] }); 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<HTMLFormElement>) => { const submitTemplate = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -326,6 +337,11 @@ export function TemplatesPage() {
.sort((a, b) => a.categoryName.localeCompare(b.categoryName)), .sort((a, b) => a.categoryName.localeCompare(b.categoryName)),
})) }))
.filter((group) => group.categories.length > 0); .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) => { const toggleServiceGroup = (serviceId: string) => {
setExpandedServiceGroups((current) => ({ setExpandedServiceGroups((current) => ({
@@ -350,6 +366,18 @@ export function TemplatesPage() {
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}> <Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
Template hinzufuegen Template hinzufuegen
</Button> </Button>
<Button
appearance="secondary"
disabled={selectedIds.length === 0 || deleteSelectedTemplates.isPending}
icon={<DeleteRegular />}
onClick={() => {
if (window.confirm(`${selectedIds.length} Templates wirklich loeschen?`)) {
deleteSelectedTemplates.mutate(selectedIds);
}
}}
>
Auswahl loeschen
</Button>
</div> </div>
<Field className={styles.filter} label="Service Filter"> <Field className={styles.filter} label="Service Filter">
<Combobox <Combobox
@@ -487,12 +515,20 @@ export function TemplatesPage() {
<DataState <DataState
isLoading={isLoading || templateCategoriesLoading || servicesLoading} isLoading={isLoading || templateCategoriesLoading || servicesLoading}
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error} error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error ?? deleteSelectedTemplates.error}
/> />
{data && ( {data && (
<Table aria-label="Templates"> <Table aria-label="Templates">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell>
<Checkbox
checked={allSelected}
onChange={(_, checkboxData) =>
setSelectedIds(checkboxData.checked ? filteredTemplates.map((template) => template.id) : [])
}
/>
</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell> <TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Template Category</TableHeaderCell> <TableHeaderCell>Template Category</TableHeaderCell>
@@ -504,7 +540,7 @@ export function TemplatesPage() {
{groupedTemplates.map((group) => ( {groupedTemplates.map((group) => (
<Fragment key={`group-${group.serviceId}`}> <Fragment key={`group-${group.serviceId}`}>
<TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}> <TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}>
<TableCell colSpan={5}> <TableCell colSpan={6}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
{expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />} {expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
<strong>{group.serviceName}</strong> <strong>{group.serviceName}</strong>
@@ -517,7 +553,7 @@ export function TemplatesPage() {
className={styles.groupRow} className={styles.groupRow}
onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)} onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)}
> >
<TableCell className={styles.categoryHeaderCell} colSpan={5}> <TableCell className={styles.categoryHeaderCell} colSpan={6}>
<div className={styles.groupHeader}> <div className={styles.groupHeader}>
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? ( {expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? (
<ChevronDownRegular /> <ChevronDownRegular />
@@ -531,6 +567,12 @@ export function TemplatesPage() {
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false && {expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false &&
categoryGroup.items.map((template) => ( categoryGroup.items.map((template) => (
<TableRow key={template.id}> <TableRow key={template.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(template.id)}
onChange={(_, checkboxData) => toggleSelected(template.id, Boolean(checkboxData.checked))}
/>
</TableCell>
<TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell> <TableCell className={styles.nestedLevel2Cell}>{template.name}</TableCell>
<TableCell>{template.version}</TableCell> <TableCell>{template.version}</TableCell>
<TableCell> <TableCell>

View File

@@ -22,19 +22,24 @@ export type AddDeploymentExecution = {
}; };
export type AddDeploymentRequest = { export type AddDeploymentRequest = {
deploymentBatchId: string; deploymentGroupId: string;
targetIds: string[]; targetIds: string[];
jsonData: string; jsonData: string;
}; };
export type DeploymentJob = { export type DeploymentJob = {
id: string; id: string;
correlationId: string;
type: string; type: string;
status: string; status: string;
priority: number;
scheduledAt?: string;
attempts: number; attempts: number;
maxAttempts: number; maxAttempts: number;
started?: string; started?: string;
finished?: string; finished?: string;
heartbeatAt?: string;
workerName?: string;
errorMessage?: string; errorMessage?: string;
targetCount: number; targetCount: number;
succeededTargetCount: number; succeededTargetCount: number;
@@ -48,7 +53,10 @@ export type DeploymentJobTarget = {
templateId: string; templateId: string;
status: string; status: string;
attempts: number; attempts: number;
started?: string;
finished?: string;
errorMessage?: string; errorMessage?: string;
outputMetadataJson?: string;
}; };
export type DeploymentJobStep = { export type DeploymentJobStep = {
@@ -59,6 +67,10 @@ export type DeploymentJobStep = {
stepType: string; stepType: string;
status: string; status: string;
metadataJson?: string; metadataJson?: string;
started?: string;
finished?: string;
outputMetadataJson?: string;
errorMessage?: string;
approvedAt?: string; approvedAt?: string;
approvedBy?: string; approvedBy?: string;
approvalComment?: string; approvalComment?: string;
@@ -66,13 +78,20 @@ export type DeploymentJobStep = {
export type DeploymentJobDetails = { export type DeploymentJobDetails = {
id: string; id: string;
correlationId: string;
type: string; type: string;
status: string; status: string;
payloadJson: string; payloadJson: string;
priority: number;
scheduledAt?: string;
attempts: number; attempts: number;
maxAttempts: number; maxAttempts: number;
started?: string; started?: string;
finished?: string; finished?: string;
heartbeatAt?: string;
workerName?: string;
lockedBy?: string;
lockedUntil?: string;
errorMessage?: string; errorMessage?: string;
targets: DeploymentJobTarget[]; targets: DeploymentJobTarget[];
steps: DeploymentJobStep[]; steps: DeploymentJobStep[];
@@ -83,6 +102,11 @@ export type DeploymentBatch = {
templateId?: string; templateId?: string;
deploymentRuleId?: string; deploymentRuleId?: string;
status?: string; status?: string;
primaryTemplateVersionId?: string;
primaryTemplateName?: string;
primaryTemplateVersion?: string;
templateSelectionCount: number;
targetAssignmentCount: number;
created?: string; created?: string;
createdBy?: string; createdBy?: string;
modified?: string; modified?: string;
@@ -97,10 +121,13 @@ export type DeploymentBatchDetails = DeploymentBatch & {
}; };
export type AddDeploymentBatch = { export type AddDeploymentBatch = {
templateId: string; templateId?: string;
templateVersionId?: string;
deploymentRuleId?: string; deploymentRuleId?: string;
status: string; status: string;
targetIds?: string[]; targetIds?: string[];
templateSelections?: AddDeploymentTemplateSelection[];
targetAssignments?: AddDeploymentTargetAssignment[];
}; };
export type DeploymentRuleStep = { export type DeploymentRuleStep = {