diff --git a/internal/annotations/annotations.go b/internal/annotations/annotations.go index 605cad4d..c4940f5f 100644 --- a/internal/annotations/annotations.go +++ b/internal/annotations/annotations.go @@ -24,6 +24,8 @@ const ( ForceApply string = "notifications.terraform.padok.cloud/force-apply" AdditionnalTriggerPaths string = "config.terraform.padok.cloud/additionnal-trigger-paths" + + SyncNow string = "api.terraform.padok.cloud/sync-now" ) func Add(ctx context.Context, c client.Client, obj client.Object, annotations map[string]string) error { diff --git a/internal/controllers/terraformlayer/conditions.go b/internal/controllers/terraformlayer/conditions.go index c9841a64..cce097dd 100644 --- a/internal/controllers/terraformlayer/conditions.go +++ b/internal/controllers/terraformlayer/conditions.go @@ -9,6 +9,7 @@ import ( configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" "github.com/padok-team/burrito/internal/annotations" + log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -243,6 +244,31 @@ func (r *Reconciler) IsApplyUpToDate(t *configv1alpha1.TerraformLayer) (metav1.C return condition, true } +func (r *Reconciler) IsSyncScheduled(t *configv1alpha1.TerraformLayer) (metav1.Condition, bool) { + condition := metav1.Condition{ + Type: "IsSyncScheduled", + ObservedGeneration: t.GetObjectMeta().GetGeneration(), + Status: metav1.ConditionUnknown, + LastTransitionTime: metav1.NewTime(time.Now()), + } + // check if annotations.SyncNow is present + if _, ok := t.Annotations[annotations.SyncNow]; ok { + condition.Reason = "SyncScheduled" + condition.Message = "A sync has been manually scheduled" + condition.Status = metav1.ConditionTrue + // Remove the annotation to avoid running the sync again + err := annotations.Remove(context.Background(), r.Client, t, annotations.SyncNow) + if err != nil { + log.Errorf("Failed to remove annotation %s from layer %s: %s", annotations.SyncNow, t.Name, err) + } + return condition, true + } + condition.Reason = "NoSyncScheduled" + condition.Message = "No sync has been manually scheduled" + condition.Status = metav1.ConditionFalse + return condition, false +} + func LayerFilesHaveChanged(layer configv1alpha1.TerraformLayer, changedFiles []string) bool { if len(changedFiles) == 0 { return true diff --git a/internal/controllers/terraformlayer/controller_test.go b/internal/controllers/terraformlayer/controller_test.go index d99e5582..bab342e1 100644 --- a/internal/controllers/terraformlayer/controller_test.go +++ b/internal/controllers/terraformlayer/controller_test.go @@ -313,6 +313,33 @@ var _ = Describe("Layer", func() { Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.DriftDetection)) }) }) + Describe("When a TerraformLayer is annotated to be manually synced", Ordered, func() { + BeforeAll(func() { + name = types.NamespacedName{ + Name: "nominal-case-8", + Namespace: "default", + } + result, layer, reconcileError, err = getResult(name) + }) + It("should still exists", func() { + Expect(err).NotTo(HaveOccurred()) + }) + It("should not return an error", func() { + Expect(reconcileError).NotTo(HaveOccurred()) + }) + It("should end in PlanNeeded state", func() { + Expect(layer.Status.State).To(Equal("PlanNeeded")) + }) + It("should set RequeueAfter to WaitAction", func() { + Expect(result.RequeueAfter).To(Equal(reconciler.Config.Controller.Timers.WaitAction)) + }) + It("should have created a plan TerraformRun", func() { + runs, err := getLinkedRuns(k8sClient, layer) + Expect(err).NotTo(HaveOccurred()) + Expect(len(runs.Items)).To(Equal(1)) + Expect(runs.Items[0].Spec.Action).To(Equal("plan")) + }) + }) }) Describe("When a TerraformLayer has errored on plan and is still before new DriftDetection tick", Ordered, func() { BeforeAll(func() { diff --git a/internal/controllers/terraformlayer/states.go b/internal/controllers/terraformlayer/states.go index eefbd796..9c9b65e1 100644 --- a/internal/controllers/terraformlayer/states.go +++ b/internal/controllers/terraformlayer/states.go @@ -25,7 +25,8 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo c3, IsLastRelevantCommitPlanned := r.IsLastRelevantCommitPlanned(layer) c4, HasLastPlanFailed := r.HasLastPlanFailed(layer) c5, IsApplyUpToDate := r.IsApplyUpToDate(layer) - conditions := []metav1.Condition{c1, c2, c3, c4, c5} + c6, IsSyncScheduled := r.IsSyncScheduled(layer) + conditions := []metav1.Condition{c1, c2, c3, c4, c5, c6} switch { case IsRunning: log.Infof("layer %s is running, waiting for the run to finish", layer.Name) @@ -33,6 +34,9 @@ func (r *Reconciler) GetState(ctx context.Context, layer *configv1alpha1.Terrafo case IsLastPlanTooOld || !IsLastRelevantCommitPlanned: log.Infof("layer %s has an outdated plan, creating a new run", layer.Name) return &PlanNeeded{}, conditions + case IsSyncScheduled: + log.Infof("layer %s has a sync scheduled, creating a new run", layer.Name) + return &PlanNeeded{}, conditions case !IsApplyUpToDate && !HasLastPlanFailed: log.Infof("layer %s needs to be applied, creating a new run", layer.Name) return &ApplyNeeded{}, conditions diff --git a/internal/controllers/terraformlayer/testdata/nominal-case.yaml b/internal/controllers/terraformlayer/testdata/nominal-case.yaml index fdfc0bd6..7fb66dff 100644 --- a/internal/controllers/terraformlayer/testdata/nominal-case.yaml +++ b/internal/controllers/terraformlayer/testdata/nominal-case.yaml @@ -201,3 +201,26 @@ status: lastRun: name: run-running namespace: default +--- +apiVersion: config.terraform.padok.cloud/v1alpha1 +kind: TerraformLayer +metadata: + labels: + app.kubernetes.io/instance: in-cluster-burrito + annotations: + api.terraform.padok.cloud/sync-now: "true" + name: nominal-case-8 + namespace: default +spec: + branch: main + path: nominal-case-eight/ + remediationStrategy: + autoApply: true + repository: + name: burrito + namespace: default + terraform: + terragrunt: + enabled: true + version: 0.45.4 + version: 1.3.1 diff --git a/internal/server/api/layers.go b/internal/server/api/layers.go index e391a8cd..f3d511a0 100644 --- a/internal/server/api/layers.go +++ b/internal/server/api/layers.go @@ -9,24 +9,26 @@ import ( "github.com/labstack/echo/v4" configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" "github.com/padok-team/burrito/internal/annotations" + "github.com/padok-team/burrito/internal/server/utils" log "github.com/sirupsen/logrus" ) type layer struct { - UID string `json:"uid"` - Name string `json:"name"` - Namespace string `json:"namespace"` - Repository string `json:"repository"` - Branch string `json:"branch"` - Path string `json:"path"` - State string `json:"state"` - RunCount int `json:"runCount"` - LastRun Run `json:"lastRun"` - LastRunAt string `json:"lastRunAt"` - LastResult string `json:"lastResult"` - IsRunning bool `json:"isRunning"` - IsPR bool `json:"isPR"` - LatestRuns []Run `json:"latestRuns"` + UID string `json:"uid"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Repository string `json:"repository"` + Branch string `json:"branch"` + Path string `json:"path"` + State string `json:"state"` + RunCount int `json:"runCount"` + LastRun Run `json:"lastRun"` + LastRunAt string `json:"lastRunAt"` + LastResult string `json:"lastResult"` + IsRunning bool `json:"isRunning"` + IsPR bool `json:"isPR"` + LatestRuns []Run `json:"latestRuns"` + ManualSyncStatus utils.ManualSyncStatus `json:"manualSyncStatus"` } type Run struct { @@ -83,20 +85,21 @@ func (a *API) LayersHandler(c echo.Context) error { running = runStillRunning(run) } results = append(results, layer{ - UID: string(l.UID), - Name: l.Name, - Namespace: l.Namespace, - Repository: fmt.Sprintf("%s/%s", l.Spec.Repository.Namespace, l.Spec.Repository.Name), - Branch: l.Spec.Branch, - Path: l.Spec.Path, - State: a.getLayerState(l), - RunCount: len(l.Status.LatestRuns), - LastRun: runAPI, - LastRunAt: l.Status.LastRun.Date.Format(time.RFC3339), - LastResult: l.Status.LastResult, - IsRunning: running, - IsPR: a.isLayerPR(l), - LatestRuns: transformLatestRuns(l.Status.LatestRuns), + UID: string(l.UID), + Name: l.Name, + Namespace: l.Namespace, + Repository: fmt.Sprintf("%s/%s", l.Spec.Repository.Namespace, l.Spec.Repository.Name), + Branch: l.Spec.Branch, + Path: l.Spec.Path, + State: a.getLayerState(l), + RunCount: len(l.Status.LatestRuns), + LastRun: runAPI, + LastRunAt: l.Status.LastRun.Date.Format(time.RFC3339), + LastResult: l.Status.LastResult, + IsRunning: running, + IsPR: a.isLayerPR(l), + LatestRuns: transformLatestRuns(l.Status.LatestRuns), + ManualSyncStatus: utils.GetManualSyncStatus(l), }) } return c.JSON(http.StatusOK, &layersResponse{ diff --git a/internal/server/api/sync.go b/internal/server/api/sync.go new file mode 100644 index 00000000..a9a05164 --- /dev/null +++ b/internal/server/api/sync.go @@ -0,0 +1,38 @@ +package api + +import ( + "context" + "net/http" + + "github.com/labstack/echo/v4" + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" + "github.com/padok-team/burrito/internal/server/utils" + log "github.com/sirupsen/logrus" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (a *API) SyncLayerHandler(c echo.Context) error { + layer := &configv1alpha1.TerraformLayer{} + err := a.Client.Get(context.Background(), client.ObjectKey{ + Namespace: c.Param("namespace"), + Name: c.Param("layer"), + }, layer) + if err != nil { + log.Errorf("could not get terraform layer: %s", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "An error occurred while getting the layer"}) + } + syncStatus := utils.GetManualSyncStatus(*layer) + if syncStatus == utils.ManualSyncAnnotated || syncStatus == utils.ManualSyncPending { + return c.JSON(http.StatusConflict, map[string]string{"error": "Layer sync already triggered"}) + } + + err = annotations.Add(context.Background(), a.Client, layer, map[string]string{ + annotations.SyncNow: "true", + }) + if err != nil { + log.Errorf("could not update terraform layer annotations: %s", err) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "An error occurred while updating the layer annotations"}) + } + return c.JSON(http.StatusOK, map[string]string{"status": "Layer sync triggered"}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6fc13e10..d220dfed 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -82,6 +82,7 @@ func (s *Server) Exec() { e.GET("/healthz", handleHealthz) api.POST("/webhook", s.Webhook.GetHttpHandler()) api.GET("/layers", s.API.LayersHandler) + api.POST("/layers/:namespace/:layer/sync", s.API.SyncLayerHandler) api.GET("/repositories", s.API.RepositoriesHandler) api.GET("/logs/:namespace/:layer/:run/:attempt", s.API.GetLogsHandler) api.GET("/run/:namespace/:layer/:run/attempts", s.API.GetAttemptsHandler) diff --git a/internal/server/utils/manual_sync.go b/internal/server/utils/manual_sync.go new file mode 100644 index 00000000..2b3efa42 --- /dev/null +++ b/internal/server/utils/manual_sync.go @@ -0,0 +1,27 @@ +package utils + +import ( + configv1alpha1 "github.com/padok-team/burrito/api/v1alpha1" + "github.com/padok-team/burrito/internal/annotations" +) + +type ManualSyncStatus string + +const ( + ManualSyncNone ManualSyncStatus = "none" + ManualSyncAnnotated ManualSyncStatus = "annotated" + ManualSyncPending ManualSyncStatus = "pending" +) + +func GetManualSyncStatus(layer configv1alpha1.TerraformLayer) ManualSyncStatus { + if layer.Annotations[annotations.SyncNow] == "true" { + return ManualSyncAnnotated + } + // check the IsSyncScheduled condition on layer + for _, c := range layer.Status.Conditions { + if c.Type == "IsSyncScheduled" && c.Status == "True" { + return ManualSyncPending + } + } + return ManualSyncNone +} diff --git a/ui/package.json b/ui/package.json index 58b78d0c..b71ee710 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,7 @@ "axios": "^1.5.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-focus-lock": "^2.13.2", "react-router-dom": "^6.16.0", "react-tooltip": "^5.21.6", "tailwind-merge": "^2.0.0" diff --git a/ui/src/clients/layers/client.ts b/ui/src/clients/layers/client.ts index 97bc9dc8..be063bf1 100644 --- a/ui/src/clients/layers/client.ts +++ b/ui/src/clients/layers/client.ts @@ -8,3 +8,10 @@ export const fetchLayers = async () => { ); return response.data; }; + +export const syncLayer = async (namespace: string, name: string) => { + const response = await axios.post( + `${import.meta.env.VITE_API_BASE_URL}/layers/${namespace}/${name}/sync` + ); + return response; +} diff --git a/ui/src/clients/layers/types.ts b/ui/src/clients/layers/types.ts index 2836cd02..2704d6bf 100644 --- a/ui/src/clients/layers/types.ts +++ b/ui/src/clients/layers/types.ts @@ -15,10 +15,12 @@ export type Layer = { latestRuns: Run[]; lastResult: string; isRunning: boolean; + manualSyncStatus: ManualSyncStatus; isPR: boolean; }; export type LayerState = "success" | "warning" | "error" | "disabled"; +export type ManualSyncStatus = "none" | "annotated" | "pending"; export type Run = { id: string; diff --git a/ui/src/components/buttons/GenericIconButton.tsx b/ui/src/components/buttons/GenericIconButton.tsx new file mode 100644 index 00000000..ba535b26 --- /dev/null +++ b/ui/src/components/buttons/GenericIconButton.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { twMerge } from "tailwind-merge"; +import { Tooltip } from "react-tooltip"; +export interface GenericIconButtonProps { + className?: string; + variant?: "light" | "dark"; + disabled?: boolean; + tooltip?: string; + width?: number; + height?: number; + onClick?: () => void; + Icon: React.FC>; +} + + +const GenericIconButton: React.FC = ({ + className, + variant, + disabled, + tooltip, + onClick, + width = 40, + height = 40, + Icon +}) => { + const hoverClass = !disabled ? (variant === "light" ? "hover:bg-primary-300" : "hover:bg-nuances-black") : ""; + return ( +
+ + +
+ ); +}; + +export default GenericIconButton; diff --git a/ui/src/components/cards/Card.tsx b/ui/src/components/cards/Card.tsx index 4f3b5de6..b7bd8bd0 100644 --- a/ui/src/components/cards/Card.tsx +++ b/ui/src/components/cards/Card.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { twMerge } from "tailwind-merge"; import { Tooltip } from "react-tooltip"; @@ -10,6 +10,9 @@ import ChiliLight from "@/assets/illustrations/ChiliLight"; import ChiliDark from "@/assets/illustrations/ChiliDark"; import { Layer } from "@/clients/layers/types"; +import GenericIconButton from "../buttons/GenericIconButton"; +import { syncLayer } from "@/clients/layers/client"; +import SyncIcon from "@/assets/icons/SyncIcon"; export interface CardProps { className?: string; @@ -30,8 +33,8 @@ const Card: React.FC = ({ path, lastResult, isRunning, - isPR, - }, + isPR + } }) => { const styles = { base: { @@ -70,6 +73,15 @@ const Card: React.FC = ({ ); }; + + const syncSelectedLayer = async (layer: Layer) => { + const sync = await syncLayer(layer.namespace, layer.name); + if (sync.status === 200) { + setIsManualSyncPending(true); + } + } + + const [isManualSyncPending, setIsManualSyncPending] = useState(layer.manualSyncStatus === "pending" || layer.manualSyncStatus === "annotated"); return (
= ({ ))}
- {layer.latestRuns.length > 0 && ( - - )} +
+ {layer.latestRuns.length > 0 && ( + + )} + syncSelectedLayer(layer)} + tooltip={isManualSyncPending ? "Sync in progress..." : "Sync now"} + /> +
= ({ className, variant = "light", isLoading, - data, + data }) => { const columnHelper = createColumnHelper(); const [hoveredRow, setHoveredRow] = useState(null); + const syncSelectedLayer = async (index: number) => { + const sync = await syncLayer(data[index].namespace, data[index].name); + if (sync.status === 200) { + data[index].manualSyncStatus = "pending"; + } + } const columns = [ columnHelper.accessor("isPR", { @@ -60,7 +70,8 @@ const Table: React.FC = ({ }), columnHelper.accessor("lastResult", { header: "Last result", - cell: (result) => ( + cell: (result) => + (
{result.getValue()} {result.row.original === hoveredRow && @@ -70,6 +81,7 @@ const Table: React.FC = ({ absolute -right-5 flex + gap-4 items-center justify-end h-[calc(100%_+_25px)] @@ -88,6 +100,11 @@ const Table: React.FC = ({ layer={result.row.original} variant={variant} /> + syncSelectedLayer(result.row.index)} + tooltip={result.row.original.manualSyncStatus === "pending" || result.row.original.manualSyncStatus === "annotated" ? "Sync in progress..." : "Sync now"} />
) : result.row.original.isRunning ? (
void; // Updated callback prop +} + +const LayerChecklist: React.FC = ({ + layers, + variant = 'light', + onSelectionChange, +}) => { + // State to keep track of selected layers using unique keys + const [selectedLayers, setSelectedLayers] = useState<{ name: string; namespace: string }[]>([]); + const selectAllRef = useRef(null); + + // Function to generate a unique key for each layer + const getLayerKey = (layer: Layer): string => `${layer.namespace}-${layer.name}`; + + // Update the indeterminate state based on selection + useEffect(() => { + if (selectAllRef.current) { + const isIndeterminate = + selectedLayers.length > 0 && selectedLayers.length < layers.length; + selectAllRef.current.indeterminate = isIndeterminate; + } + }, [selectedLayers, layers.length]); + + // Handler for individual layer checkbox toggle + const handleToggle = (layer: Layer) => { + setSelectedLayers((prevSelected) => + prevSelected.some((selectedLayer) => selectedLayer.name === layer.name && selectedLayer.namespace === layer.namespace) + ? prevSelected.filter((selectedLayer) => selectedLayer.name !== layer.name || selectedLayer.namespace !== layer.namespace) + : [...prevSelected, { name: layer.name, namespace: layer.namespace }] + ); + }; + + // Handler to select all layers + const handleSelectAll = () => { + setSelectedLayers(layers.map(layer => ({ name: layer.name, namespace: layer.namespace }))); + }; + + // Handler to unselect all layers + const handleUnselectAll = () => { + setSelectedLayers([]); + }; + + useEffect(() => { + if (onSelectionChange) { + onSelectionChange(selectedLayers); + } + }, [selectedLayers, onSelectionChange]); + + return ( +
+
+ + +
+
    + {layers.map((layer) => { + const key = getLayerKey(layer); + return ( +
  • +
    + selectedLayer.name === layer.name && selectedLayer.namespace === layer.namespace)} + onChange={() => handleToggle(layer)} + variant={variant} + /> + +
    +
  • + ); + })} +
+
+ ); +}; + +export default LayerChecklist; diff --git a/ui/src/components/widgets/ProgressBar.tsx b/ui/src/components/widgets/ProgressBar.tsx new file mode 100644 index 00000000..e965deb1 --- /dev/null +++ b/ui/src/components/widgets/ProgressBar.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +interface ProgressBarProps { + /** + * Progress value between 0 and 100 + */ + value: number; + label?: string; + color?: string; + className?: string; +} + +const ProgressBar: React.FC = ({ + value, + label, + color = 'bg-blue-500', + className = '', +}) => { + // Ensure the value is between 0 and 100 + const normalizedValue = Math.min(Math.max(value, 0), 100); + + return ( +
+
+ {label && ( + + {label} + + )} +
+
+ ); +}; + +export default ProgressBar; diff --git a/ui/src/modals/SlidingPane.tsx b/ui/src/modals/SlidingPane.tsx new file mode 100644 index 00000000..314b7ab5 --- /dev/null +++ b/ui/src/modals/SlidingPane.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import FocusLock from 'react-focus-lock'; + +interface SlidingPaneProps { + isOpen: boolean; + onClose: () => void; + children?: React.ReactNode; + width?: string; +} + +const SlidingPane: React.FC = ({ + isOpen, + onClose, + children, + width = 'w-1/3', +}) => { + // Handle Escape key to close the pane + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + // Prevent background scrolling when pane is open + useEffect(() => { + if (isOpen) { + document.body.classList.add('overflow-hidden'); + } else { + document.body.classList.remove('overflow-hidden'); + } + + return () => { + document.body.classList.remove('overflow-hidden'); + }; + }, [isOpen]); + + + return ReactDOM.createPortal( + <> + {/* Background */} +
+ + {/* Sliding Pane */} + +
+ {/* Close Button */} + + {/* Content */} +
{children}
+
+
+ , + document.body + ); +}; + +export default SlidingPane; diff --git a/ui/src/pages/Layers.tsx b/ui/src/pages/Layers.tsx index 9016a5f1..6d10d51e 100644 --- a/ui/src/pages/Layers.tsx +++ b/ui/src/pages/Layers.tsx @@ -2,7 +2,7 @@ import React, { useState, useContext, useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { fetchLayers } from "@/clients/layers/client"; +import { fetchLayers, syncLayer } from "@/clients/layers/client"; import { reactQueryKeys } from "@/clients/reactQueryConfig"; import { ThemeContext } from "@/contexts/ThemeContext"; @@ -23,6 +23,9 @@ import CardLoader from "@/components/loaders/CardLoader"; import { LayerState } from "@/clients/layers/types"; import PaginationDropdown from "@/components/dropdowns/PaginationDropdown"; +import SlidingPane from "@/modals/SlidingPane"; +import LayerChecklist from "@/components/tools/LayerChecklist"; +import ProgressBar from "@/components/widgets/ProgressBar"; const Layers: React.FC = () => { const { theme } = useContext(ThemeContext); @@ -83,6 +86,8 @@ const Layers: React.FC = () => { [searchParams, setSearchParams] ); + const [showRefreshPane, setShowRefreshPane] = useState(false); + const layersQuery = useQuery({ queryKey: reactQueryKeys.layers, queryFn: fetchLayers, @@ -117,8 +122,59 @@ const Layers: React.FC = () => { [layerOffset, layersQuery] ); + const [selectedLayersForSync, setSelectedLayersForSync] = useState<{ name: string; namespace: string }[]>([]); + const [syncProgressValue, setSyncProgressValue] = useState(0); + const syncSelectedLayers = async () => { + const totalLayers = selectedLayersForSync.length; + for (const layer of selectedLayersForSync) { + try { + await syncLayer(layer.namespace, layer.name); + } catch (error) { + console.error(`Failed to sync layer ${layer.name}:`, error); + } + setSyncProgressValue((prev) => prev + 100 / totalLayers) + } + setTimeout(() => { + setSyncProgressValue(0); + setShowRefreshPane(false); + layersQuery.refetch(); + }, 1000); + } + return (
+ setShowRefreshPane(false)}> +
+
+

+ Select Layers to synchronize +

+ + {layersQuery.isSuccess && ( + setSelectedLayersForSync(layers)}/> + )} +
+
+ + +
+
+
{ > Layers - +
+ + +
{ ) : layersQuery.isSuccess ? ( layersQuery.data.results.length > 0 ? ( - +
) : (
{ isLoading={layersQuery.isRefetching} onClick={() => layersQuery.refetch()} > - Refresh layers + Refresh