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:
@@ -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 (
|
||||
<div className={styles.root}>
|
||||
@@ -111,7 +132,12 @@ export function AppShell() {
|
||||
<section className={styles.content}>
|
||||
<header className={styles.topbar}>
|
||||
<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>
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
|
||||
@@ -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() {
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{!isLoading && !error && (
|
||||
<div className={styles.grid}>
|
||||
<MetricCard label="Domains" value={domains.data?.length ?? 0} />
|
||||
<MetricCard label="Environments" value={environments.data?.length ?? 0} />
|
||||
<MetricCard label="Templates" value={templates.data?.length ?? 0} />
|
||||
<MetricCard label="Services" value={services.data?.length ?? 0} />
|
||||
<MetricCard label="Domains" value={domains.data?.length ?? 0} to="/domains" />
|
||||
<MetricCard label="Environments" value={environments.data?.length ?? 0} to="/environments" />
|
||||
<MetricCard label="Templates" value={templates.data?.length ?? 0} to="/templates" />
|
||||
<MetricCard label="Services" value={services.data?.length ?? 0} to="/services" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && (
|
||||
<div className={styles.chartGrid}>
|
||||
<DonutCard
|
||||
title="Jobs"
|
||||
emptyText="Keine Worker Jobs vorhanden."
|
||||
items={Object.entries(queueByStatus).map(([label, value], index) => ({
|
||||
label,
|
||||
value,
|
||||
@@ -139,6 +170,7 @@ export function DashboardPage() {
|
||||
/>
|
||||
<DonutCard
|
||||
title="VM DSC Compliance"
|
||||
emptyText="Keine Compliance-Daten gemeldet."
|
||||
items={[
|
||||
{ label: "Compliant", value: vmCompliance.compliant, color: "#107C10" },
|
||||
{ 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();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Link className={styles.metricLink} to={to}>
|
||||
<Card className={styles.metricCard}>
|
||||
<CardHeader header={<Text weight="semibold">{label}</Text>} />
|
||||
<Text className={styles.metric}>{value}</Text>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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,6 +219,11 @@ function DonutCard({
|
||||
<Card>
|
||||
<CardHeader header={<Text weight="semibold">{title}</Text>} />
|
||||
<Divider />
|
||||
{total === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Text>{emptyText}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.donutWrap}>
|
||||
<div className={styles.donut} style={{ background: `conic-gradient(${segments})` }} />
|
||||
<div className={styles.legend}>
|
||||
@@ -197,6 +238,7 @@ function DonutCard({
|
||||
<Text>Total: {total}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [selectedParameterValueIds, setSelectedParameterValueIds] = useState<string[]>([]);
|
||||
const [selectedTargetAssignmentIds, setSelectedTargetAssignmentIds] = useState<string[]>([]);
|
||||
|
||||
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<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 (
|
||||
<div className={styles.layout}>
|
||||
<div className={styles.headerActions}>
|
||||
<Link to="/deployments">
|
||||
<Button appearance="subtle" icon={<ArrowLeftRegular />}>
|
||||
Deployments
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!canQueueDeployment || queueDeployment.isPending}
|
||||
icon={<AddRegular />}
|
||||
onClick={() => queueDeployment.mutate()}
|
||||
>
|
||||
Queue Deployment
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader title={id ? `Deployment Batch ${id}` : "Deployment Batch"} description="Composition, Targets und Executions fuer diesen Batch." />
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error ?? templateSelectionError ?? parameterError ?? targetError}
|
||||
error={
|
||||
error ??
|
||||
templateSelectionError ??
|
||||
deleteSelectedTemplateSelections.error ??
|
||||
parameterError ??
|
||||
deleteSelectedParameterValues.error ??
|
||||
targetError ??
|
||||
deleteSelectedTargetAssignments.error ??
|
||||
queueError
|
||||
}
|
||||
/>
|
||||
|
||||
<section className={styles.section}>
|
||||
@@ -207,6 +292,18 @@ export function DeploymentBatchDetailsPage() {
|
||||
>
|
||||
Add
|
||||
</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 className={styles.formGrid}>
|
||||
<Field label="Template">
|
||||
@@ -249,6 +346,14 @@ export function DeploymentBatchDetailsPage() {
|
||||
<Table aria-label="Template selections">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allTemplateSelectionsSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedTemplateSelectionIds(checkboxData.checked ? templateSelections.map((selection) => selection.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Order</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>Alias</TableHeaderCell>
|
||||
@@ -259,8 +364,16 @@ export function DeploymentBatchDetailsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(deploymentBatch?.templateSelections ?? []).map((selection) => (
|
||||
{templateSelections.map((selection) => (
|
||||
<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.templateRole}</TableCell>
|
||||
<TableCell>{selection.alias ?? "-"}</TableCell>
|
||||
@@ -295,6 +408,18 @@ export function DeploymentBatchDetailsPage() {
|
||||
>
|
||||
Add
|
||||
</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 className={styles.formGrid}>
|
||||
<Field label="Scope">
|
||||
@@ -329,6 +454,14 @@ export function DeploymentBatchDetailsPage() {
|
||||
<Table aria-label="Parameter values">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allParameterValuesSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedParameterValueIds(checkboxData.checked ? parameterValues.map((parameterValue) => parameterValue.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Secret</TableHeaderCell>
|
||||
@@ -337,13 +470,21 @@ export function DeploymentBatchDetailsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(deploymentBatch?.parameterValues ?? []).map((parameterValue) => {
|
||||
const selection = (deploymentBatch?.templateSelections ?? []).find(
|
||||
{parameterValues.map((parameterValue) => {
|
||||
const selection = templateSelections.find(
|
||||
(entry) => entry.id === parameterValue.deploymentTemplateSelectionId,
|
||||
);
|
||||
|
||||
return (
|
||||
<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>{parameterValue.name}</TableCell>
|
||||
<TableCell>{parameterValue.isSecretReference ? "Yes" : "No"}</TableCell>
|
||||
@@ -377,6 +518,18 @@ export function DeploymentBatchDetailsPage() {
|
||||
>
|
||||
Add
|
||||
</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 className={styles.formGrid}>
|
||||
<Field label="Target">
|
||||
@@ -402,6 +555,14 @@ export function DeploymentBatchDetailsPage() {
|
||||
<Table aria-label="Target assignments">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allTargetAssignmentsSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedTargetAssignmentIds(checkboxData.checked ? targetAssignments.map((targetAssignment) => targetAssignment.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Order</TableHeaderCell>
|
||||
<TableHeaderCell>Target</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
@@ -410,8 +571,16 @@ export function DeploymentBatchDetailsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(deploymentBatch?.targetAssignments ?? []).map((targetAssignment) => (
|
||||
{targetAssignments.map((targetAssignment) => (
|
||||
<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.target?.name ?? targetAssignment.targetId}</TableCell>
|
||||
<TableCell>{targetAssignment.roleKey}</TableCell>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [targetSearch, setTargetSearch] = useState("");
|
||||
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({
|
||||
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,46 +177,151 @@ export function DeploymentGroupsPage() {
|
||||
filteredTargets.length > 0 &&
|
||||
filteredTargets.every((target) => targetIds.includes(target.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Deployments" description="Deployment Batches und deren Ausfuehrungen." />
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}>
|
||||
Neues Deployment
|
||||
</Button>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
{data && (
|
||||
<Table aria-label="Deployment batches">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Service</TableHeaderCell>
|
||||
<TableHeaderCell>Template</TableHeaderCell>
|
||||
<TableHeaderCell>Rule</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Modified</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((deploymentBatch) => (
|
||||
<TableRow key={deploymentBatch.id}>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="Deployments" description="Deployment Batches und Compositionen nach Status." />
|
||||
<div className={styles.pageActions}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={() => setDialogOpen(true)}>
|
||||
Neuer Deployment Batch
|
||||
</Button>
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedIds(checkboxData.checked ? (data ?? []).map((deploymentBatch) => deploymentBatch.id) : [])
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedBatches.map(([status, deploymentBatches]) => (
|
||||
<Fragment key={`status-${status}`}>
|
||||
<TableRow className={styles.groupRow} onClick={() => toggleStatusGroup(status)}>
|
||||
<TableCell colSpan={9}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedStatusGroups[status] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<strong>{status}</strong>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{(templates ?? []).find((entry) => entry.id === deploymentBatch.templateId)?.name ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
{expandedStatusGroups[status] !== false &&
|
||||
deploymentBatches.map((deploymentBatch) => (
|
||||
<TableRow key={deploymentBatch.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(deploymentBatch.id)}
|
||||
onChange={(_, checkboxData) => toggleSelected(deploymentBatch.id, Boolean(checkboxData.checked))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={styles.nestedLevel2Cell}>{getServiceName(deploymentBatch)}</TableCell>
|
||||
<TableCell>{getPrimaryTemplateLabel(deploymentBatch)}</TableCell>
|
||||
<TableCell>{deploymentBatch.templateSelectionCount ?? 0}</TableCell>
|
||||
<TableCell>{deploymentBatch.targetAssignmentCount ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
{(deploymentRules ?? []).find((entry) => entry.id === deploymentBatch.deploymentRuleId)?.name ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>{deploymentBatch.status ?? "Unknown"}</TableCell>
|
||||
<TableCell>{deploymentBatch.created ? new Date(deploymentBatch.created).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>{deploymentBatch.modified ? new Date(deploymentBatch.modified).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>{deploymentBatch.id}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip content={deploymentBatch.id} relationship="label">
|
||||
<span className={styles.idText}>{deploymentBatch.id.slice(0, 8)}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={styles.actions}>
|
||||
<Tooltip content="Details" relationship="label">
|
||||
@@ -158,21 +334,27 @@ export function DeploymentGroupsPage() {
|
||||
appearance="subtle"
|
||||
aria-label="Delete"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => deleteDeploymentBatch.mutate(deploymentBatch.id)}
|
||||
onClick={() => setDeleteCandidate(deploymentBatch)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(_, data) => setDialogOpen(data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Neues Deployment</DialogTitle>
|
||||
<DialogTitle>Neuer Deployment Batch</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text className={styles.dialogIntro}>
|
||||
Erstellt eine neue Deployment Composition mit einer initialen Template Selection.
|
||||
</Text>
|
||||
<Field label="Service" required>
|
||||
<Combobox
|
||||
placeholder="Service waehlen"
|
||||
@@ -181,6 +363,7 @@ export function DeploymentGroupsPage() {
|
||||
const nextServiceId = data.optionValue ?? "";
|
||||
setServiceId(nextServiceId);
|
||||
setTemplateId("");
|
||||
setTemplateVersionId("");
|
||||
setTargetIds([]);
|
||||
}}
|
||||
>
|
||||
@@ -191,12 +374,15 @@ export function DeploymentGroupsPage() {
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field label="Template" required>
|
||||
<Field label="Initial Template" required>
|
||||
<Combobox
|
||||
placeholder={serviceId ? "Template waehlen" : "Erst Service waehlen"}
|
||||
disabled={!serviceId}
|
||||
value={templates?.find((template) => 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() {
|
||||
))}
|
||||
</Combobox>
|
||||
</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">
|
||||
<Combobox
|
||||
placeholder="Rule waehlen (optional)"
|
||||
@@ -224,10 +425,10 @@ export function DeploymentGroupsPage() {
|
||||
</Combobox>
|
||||
</Field>
|
||||
{isOnPremService && (
|
||||
<Field label="Targets" required>
|
||||
<div>
|
||||
<Field label="Initial Targets" required>
|
||||
<div className={styles.targetPicker}>
|
||||
<Input
|
||||
placeholder="VM suchen (Name oder Id)"
|
||||
placeholder="Target suchen (Name oder Id)"
|
||||
value={targetSearch}
|
||||
onChange={(_, data) => 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)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -269,7 +470,7 @@ export function DeploymentGroupsPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{target.name}</TableCell>
|
||||
<TableCell>{target.id}</TableCell>
|
||||
<TableCell>{target.providerType}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -284,17 +485,44 @@ export function DeploymentGroupsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!templateId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending}
|
||||
disabled={!templateId || !templateVersionId || (isOnPremService && targetIds.length === 0) || addDeploymentBatch.isPending}
|
||||
onClick={() =>
|
||||
addDeploymentBatch.mutate({
|
||||
status: "New",
|
||||
templateId,
|
||||
templateVersionId,
|
||||
deploymentRuleId: deploymentRuleId || 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>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
@@ -303,5 +531,3 @@ export function DeploymentGroupsPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<DeploymentRuleStep[]>([createDefaultStep(1)]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PageHeader title="Deployment Rules" description="Low-Code Regeln fuer Deployment Pipelines gestalten." />
|
||||
<div className={styles.toolbar}>
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Neue Rule
|
||||
</Button>
|
||||
<DataState isLoading={isLoading} error={error} />
|
||||
<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 && (
|
||||
<Table aria-label="Deployment rules">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) => setSelectedIds(checkboxData.checked ? data.map((rule) => rule.id) : [])}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Steps</TableHeaderCell>
|
||||
@@ -186,6 +225,12 @@ export function DeploymentRulesPage() {
|
||||
<TableBody>
|
||||
{data.map((rule) => (
|
||||
<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.isActive ? "Active" : "Inactive"}</TableCell>
|
||||
<TableCell>{rule.steps?.length ?? 0}</TableCell>
|
||||
|
||||
@@ -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() {
|
||||
<FormSection
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
addDeploymentRequest.mutate({ deploymentBatchId, jsonData, targetIds: selectedTargetIds });
|
||||
addDeploymentRequest.mutate({ deploymentGroupId: deploymentBatchId, jsonData, targetIds: selectedTargetIds });
|
||||
}}
|
||||
>
|
||||
<FormGrid>
|
||||
@@ -109,7 +111,7 @@ export function DeploymentsPage() {
|
||||
<FormActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={!deploymentBatchId || selectedTargetIds.length === 0 || addDeploymentRequest.isPending}
|
||||
disabled={!deploymentBatchId || (!canUseCompositionTargets && selectedTargetIds.length === 0) || addDeploymentRequest.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Start deployment
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
@@ -59,6 +60,7 @@ export function DomainsPage() {
|
||||
const [netBIOS, setNetBIOS] = useState("");
|
||||
const [linkDomainId, setLinkDomainId] = useState("");
|
||||
const [environmentId, setEnvironmentId] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: ({ signal }) => portalApi.getDomains(signal),
|
||||
@@ -120,6 +122,15 @@ export function DomainsPage() {
|
||||
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({
|
||||
mutationFn: ({ domainId, environmentId: targetEnvironmentId }: { domainId: string; environmentId: string }) =>
|
||||
portalApi.linkDomainToEnvironment(domainId, targetEnvironmentId),
|
||||
@@ -145,6 +156,10 @@ export function DomainsPage() {
|
||||
|
||||
const formError = addDomain.error?.message ?? updateDomain.error?.message;
|
||||
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 (
|
||||
<>
|
||||
@@ -156,6 +171,18 @@ export function DomainsPage() {
|
||||
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
|
||||
Link to Environment
|
||||
</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>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
@@ -251,11 +278,20 @@ export function DomainsPage() {
|
||||
</DialogSurface>
|
||||
</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 && (
|
||||
<Table aria-label="Domains">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) => setSelectedIds(checkboxData.checked ? data.map((domain) => domain.id) : [])}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>FQDN</TableHeaderCell>
|
||||
<TableHeaderCell>NetBIOS</TableHeaderCell>
|
||||
@@ -266,6 +302,12 @@ export function DomainsPage() {
|
||||
<TableBody>
|
||||
{data.map((domain) => (
|
||||
<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.fqdn}</TableCell>
|
||||
<TableCell>{domain.netBIOS}</TableCell>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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() {
|
||||
<Button appearance="secondary" icon={<LinkMultiple24Regular />} onClick={openLinkDialog}>
|
||||
Link to Domain
|
||||
</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>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, data) => !data.open && closeDialog()}>
|
||||
@@ -368,11 +395,22 @@ export function EnvironmentsPage() {
|
||||
</DialogSurface>
|
||||
</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 && (
|
||||
<Table aria-label="Environments">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedIds(checkboxData.checked ? data.map((environment) => environment.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Stage</TableHeaderCell>
|
||||
<TableHeaderCell>Hosting</TableHeaderCell>
|
||||
@@ -387,6 +425,12 @@ export function EnvironmentsPage() {
|
||||
<TableBody>
|
||||
{data.map((environment) => (
|
||||
<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.environmentType}</TableCell>
|
||||
<TableCell>{environment.hostingType}</TableCell>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
|
||||
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() {
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Service hinzufuegen
|
||||
</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>
|
||||
<Field className={styles.filter} label="Service Filter">
|
||||
<Combobox
|
||||
@@ -498,11 +525,19 @@ export function ServicesPage() {
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading} error={error ?? deleteService.error} />
|
||||
<DataState isLoading={isLoading} error={error ?? deleteService.error ?? deleteSelectedServices.error} />
|
||||
{data && (
|
||||
<Table aria-label="Services">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedIds(checkboxData.checked ? filteredServices.map((service) => service.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
@@ -513,7 +548,7 @@ export function ServicesPage() {
|
||||
{groupedServices.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<TableRow className={styles.groupRow} onClick={() => toggleGroup(group.key)}>
|
||||
<TableCell colSpan={4}>
|
||||
<TableCell colSpan={5}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedGroups[group.key] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<strong>{group.label}</strong>
|
||||
@@ -522,6 +557,12 @@ export function ServicesPage() {
|
||||
</TableRow>
|
||||
{expandedGroups[group.key] !== false && group.items.map((service) => (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(service.id)}
|
||||
onChange={(_, checkboxData) => toggleSelected(service.id, Boolean(checkboxData.checked))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={styles.nestedCell}>
|
||||
<div className={styles.nameWithIcon}>
|
||||
{getServiceIcon(service.iconKey)}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
|
||||
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
|
||||
</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>
|
||||
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(_, dialogData) => !dialogData.open && closeDialog()}>
|
||||
@@ -373,12 +401,18 @@ export function TargetsPage() {
|
||||
|
||||
<DataState
|
||||
isLoading={isLoading || domainsLoading}
|
||||
error={error ?? domainsError ?? deleteTarget.error ?? linkTargetToDomain.error ?? unlinkTargetFromDomain.error}
|
||||
error={error ?? domainsError ?? deleteTarget.error ?? deleteSelectedTargets.error ?? linkTargetToDomain.error ?? unlinkTargetFromDomain.error}
|
||||
/>
|
||||
{data && (
|
||||
<Table aria-label="Targets">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) => setSelectedIds(checkboxData.checked ? data.map((target) => target.id) : [])}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
@@ -391,6 +425,12 @@ export function TargetsPage() {
|
||||
<TableBody>
|
||||
{data.map((target) => (
|
||||
<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.targetType}</TableCell>
|
||||
<TableCell>{target.providerType}</TableCell>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
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<HTMLFormElement>) => {
|
||||
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() {
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template Category hinzufuegen
|
||||
</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>
|
||||
<Field className={styles.filter} label="Service Filter">
|
||||
<Combobox
|
||||
@@ -316,11 +344,24 @@ export function TemplateCategoriesPage() {
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<DataState isLoading={isLoading || servicesLoading} error={error ?? servicesError ?? deleteTemplateCategory.error} />
|
||||
<DataState
|
||||
isLoading={isLoading || servicesLoading}
|
||||
error={error ?? servicesError ?? deleteTemplateCategory.error ?? deleteSelectedTemplateCategories.error}
|
||||
/>
|
||||
{data && (
|
||||
<Table aria-label="Template Categories">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedIds(
|
||||
checkboxData.checked ? filteredTemplateCategories.map((templateCategory) => templateCategory.id) : [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Active</TableHeaderCell>
|
||||
<TableHeaderCell>Id</TableHeaderCell>
|
||||
@@ -335,7 +376,7 @@ export function TemplateCategoriesPage() {
|
||||
key={`group-${group.serviceId}`}
|
||||
onClick={() => toggleGroup(group.serviceId)}
|
||||
>
|
||||
<TableCell colSpan={4}>
|
||||
<TableCell colSpan={5}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<strong>{group.serviceName}</strong>
|
||||
@@ -344,6 +385,12 @@ export function TemplateCategoriesPage() {
|
||||
</TableRow>
|
||||
{expandedGroups[group.serviceId] !== false && group.items.map((templateCategory) => (
|
||||
<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>{templateCategory.isActive ? "Ja" : "Nein"}</TableCell>
|
||||
<TableCell>{templateCategory.id}</TableCell>
|
||||
|
||||
@@ -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<TemplateEditorTab>("parameters");
|
||||
const [serviceFilterId, setServiceFilterId] = useState("all");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [expandedServiceGroups, setExpandedServiceGroups] = useState<Record<string, boolean>>({});
|
||||
const [expandedCategoryGroups, setExpandedCategoryGroups] = useState<Record<string, boolean>>({});
|
||||
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<HTMLFormElement>) => {
|
||||
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() {
|
||||
<Button appearance="primary" icon={<AddRegular />} onClick={openAddDialog}>
|
||||
Template hinzufuegen
|
||||
</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>
|
||||
<Field className={styles.filter} label="Service Filter">
|
||||
<Combobox
|
||||
@@ -487,12 +515,20 @@ export function TemplatesPage() {
|
||||
|
||||
<DataState
|
||||
isLoading={isLoading || templateCategoriesLoading || servicesLoading}
|
||||
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error}
|
||||
error={error ?? templateCategoriesError ?? servicesError ?? deleteTemplate.error ?? deleteSelectedTemplates.error}
|
||||
/>
|
||||
{data && (
|
||||
<Table aria-label="Templates">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onChange={(_, checkboxData) =>
|
||||
setSelectedIds(checkboxData.checked ? filteredTemplates.map((template) => template.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Template Category</TableHeaderCell>
|
||||
@@ -504,7 +540,7 @@ export function TemplatesPage() {
|
||||
{groupedTemplates.map((group) => (
|
||||
<Fragment key={`group-${group.serviceId}`}>
|
||||
<TableRow className={styles.groupRow} onClick={() => toggleServiceGroup(group.serviceId)}>
|
||||
<TableCell colSpan={5}>
|
||||
<TableCell colSpan={6}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedServiceGroups[group.serviceId] !== false ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<strong>{group.serviceName}</strong>
|
||||
@@ -517,7 +553,7 @@ export function TemplatesPage() {
|
||||
className={styles.groupRow}
|
||||
onClick={() => toggleCategoryGroup(group.serviceId, categoryGroup.categoryId)}
|
||||
>
|
||||
<TableCell className={styles.categoryHeaderCell} colSpan={5}>
|
||||
<TableCell className={styles.categoryHeaderCell} colSpan={6}>
|
||||
<div className={styles.groupHeader}>
|
||||
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false ? (
|
||||
<ChevronDownRegular />
|
||||
@@ -531,6 +567,12 @@ export function TemplatesPage() {
|
||||
{expandedCategoryGroups[`${group.serviceId}:${categoryGroup.categoryId}`] !== false &&
|
||||
categoryGroup.items.map((template) => (
|
||||
<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>{template.version}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user