diff --git a/AGENTS.md b/AGENTS.md index 7009245..17a3767 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,8 @@ Pipeline logs are streamed via WebSocket (`PipelineLogWebSocketHandler`). A `@Sc ### Application Deployment Applications deploy as **StatefulSets** (not Deployments) with `enableServiceLinks(false)` to prevent K8s env var pollution. Ingress uses **Traefik IngressRoute CRDs** (not standard Kubernetes Ingress) — the code checks for CRD existence and skips gracefully if absent. ConfigMap named after the application is injected via `envFrom: configMapRef`. +**Config item metadata (no DB table)**: Env/file config items carry UI-only metadata — `mountPath` (file mounts), plus `group` and `comment` for organizing and documenting keys in the editor. Rather than a new table, `KubernetesConfigMapGateway` serializes this as JSON in resource annotations on each `{app}` / `{app}.files` ConfigMap/Secret: `oops.mounts` (`key → mountPath`) and `oops.config-meta` (`key → {group, comment}`). Each annotation only describes the keys in its own resource's `data`, so items moving between env/secret/mounted carry their metadata along on the next full-rewrite save. The metadata never enters the container. Frontend `application-config-info.tsx` renders collapsible per-group sections with key/comment search; `env-import-dialog.tsx` round-trips groups as `## group: xxx` markers and comments as `#` lines in the dotenv import/export format. + Deployment triggering logic lives in `DeploymentService` (not `PipelineService`). The `DeploymentController` at `/api/namespaces/{namespace}/applications/{name}/deployments` handles deploy triggers and source uploads. **Deploy processor chain**: `ArtifactDeployTask` orchestrates deployment via a sequence of `DeployProcessor`s sharing a `DeployContext`: diff --git a/src/main/java/com/github/wellch4n/oops/application/dto/ConfigMapItem.java b/src/main/java/com/github/wellch4n/oops/application/dto/ConfigMapItem.java index 3f6822e..b3b495e 100644 --- a/src/main/java/com/github/wellch4n/oops/application/dto/ConfigMapItem.java +++ b/src/main/java/com/github/wellch4n/oops/application/dto/ConfigMapItem.java @@ -23,6 +23,24 @@ public class ConfigMapItem { */ private String mountPath; + /** + * Optional display group used to organize items in the UI. Stored as metadata in the + * {@code oops.config-meta} annotation, never in the resource data. + */ + private String group; + + /** + * Optional free-text note describing the item. Stored as metadata in the + * {@code oops.config-meta} annotation, never in the resource data. + */ + private String comment; + + /** + * Optional display position. Persisted in the {@code oops.config-meta} annotation so a manual ordering + * survives the unordered ConfigMap/Secret data map. + */ + private Integer order; + public String getName() { return toResourceName(key); } diff --git a/src/main/java/com/github/wellch4n/oops/application/dto/UpdateConfigMapCommand.java b/src/main/java/com/github/wellch4n/oops/application/dto/UpdateConfigMapCommand.java index e0dcd55..413f5c6 100644 --- a/src/main/java/com/github/wellch4n/oops/application/dto/UpdateConfigMapCommand.java +++ b/src/main/java/com/github/wellch4n/oops/application/dto/UpdateConfigMapCommand.java @@ -22,4 +22,16 @@ public class UpdateConfigMapCommand { * container; when blank the item is exposed as an environment variable only. */ private String mountPath; + + /** + * Optional display group used to organize items in the UI. Stored as metadata in the + * {@code oops.config-meta} annotation, never in the resource data. + */ + private String group; + + /** + * Optional free-text note describing the item. Stored as metadata in the + * {@code oops.config-meta} annotation, never in the resource data. + */ + private String comment; } diff --git a/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesConfigMapGateway.java b/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesConfigMapGateway.java index 2783af0..f86f538 100644 --- a/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesConfigMapGateway.java +++ b/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesConfigMapGateway.java @@ -1,5 +1,6 @@ package com.github.wellch4n.oops.infrastructure.kubernetes; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.wellch4n.oops.application.port.ConfigMapGateway; @@ -19,6 +20,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -47,6 +49,13 @@ public class KubernetesConfigMapGateway implements ConfigMapGateway { */ public static final String FILES_RESOURCE_SUFFIX = ".files"; + /** + * Annotation on the application ConfigMaps/Secrets holding the {@code key -> {group, comment}} map for + * the items each resource carries. Pure UI metadata: it organizes and documents items in the config + * editor and never reaches the container. + */ + public static final String CONFIG_META_ANNOTATION = "oops.config-meta"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final KubernetesClientPool clientPool; @@ -63,28 +72,39 @@ public List getConfigMaps(Environment environment, String namespa ConfigMap envConfigMap = client.configMaps().inNamespace(namespace).withName(applicationName).get(); if (envConfigMap != null && envConfigMap.getData() != null) { - envConfigMap.getData().forEach((key, value) -> items.add(buildItem(key, value, false, null))); + Map metas = readConfigMetas(envConfigMap.getMetadata()); + envConfigMap.getData().forEach((key, value) -> + items.add(buildItem(key, value, false, null, metas.get(key)))); } Secret envSecret = client.secrets().inNamespace(namespace).withName(applicationName).get(); if (envSecret != null && envSecret.getData() != null) { - envSecret.getData().forEach((key, value) -> items.add(buildItem(key, decode(value), true, null))); + Map metas = readConfigMetas(envSecret.getMetadata()); + envSecret.getData().forEach((key, value) -> + items.add(buildItem(key, decode(value), true, null, metas.get(key)))); } ConfigMap fileConfigMap = client.configMaps().inNamespace(namespace).withName(filesName).get(); if (fileConfigMap != null && fileConfigMap.getData() != null) { Map mounts = readMounts(fileConfigMap.getMetadata()); + Map metas = readConfigMetas(fileConfigMap.getMetadata()); fileConfigMap.getData().forEach((key, value) -> - items.add(buildItem(key, value, false, mounts.get(key)))); + items.add(buildItem(key, value, false, mounts.get(key), metas.get(key)))); } Secret fileSecret = client.secrets().inNamespace(namespace).withName(filesName).get(); if (fileSecret != null && fileSecret.getData() != null) { Map mounts = readMounts(fileSecret.getMetadata()); + Map metas = readConfigMetas(fileSecret.getMetadata()); fileSecret.getData().forEach((key, value) -> - items.add(buildItem(key, decode(value), true, mounts.get(key)))); + items.add(buildItem(key, decode(value), true, mounts.get(key), metas.get(key)))); } + // Restore the editor's manual ordering from the order meta. Items without an order (legacy data, + // freshly added keys) sort last, keyed by name for a stable, predictable fallback. + items.sort(Comparator + .comparing((ConfigMapItem item) -> item.getOrder() == null ? Integer.MAX_VALUE : item.getOrder()) + .thenComparing(ConfigMapItem::getKey)); return items; } @@ -108,27 +128,39 @@ public void updateConfigMap(Environment environment, // resources (injected wholesale via envFrom); mounted items go into {app}.files (projected as files) // so they are never exposed as environment variables. Map envConfig = new HashMap<>(); + Map envConfigMetas = new LinkedHashMap<>(); Map envSecret = new HashMap<>(); + Map envSecretMetas = new LinkedHashMap<>(); Map fileConfig = new LinkedHashMap<>(); Map fileConfigMounts = new LinkedHashMap<>(); + Map fileConfigMetas = new LinkedHashMap<>(); Map fileSecret = new LinkedHashMap<>(); Map fileSecretMounts = new LinkedHashMap<>(); + Map fileSecretMetas = new LinkedHashMap<>(); + // The incoming list is already in display order; persist each item's index as its order meta so the + // arrangement (including manual drag reordering) survives the unordered ConfigMap data map. + int position = 0; for (UpdateConfigMapCommand command : configMaps) { boolean mounted = StringUtils.isNotBlank(command.getMountPath()); + ConfigMeta meta = ConfigMeta.of(command.getGroup(), command.getComment(), position++); if (command.isSecret()) { if (mounted) { fileSecret.put(command.getKey(), command.getValue()); fileSecretMounts.put(command.getKey(), command.getMountPath().trim()); + putMeta(fileSecretMetas, command.getKey(), meta); } else { envSecret.put(command.getKey(), command.getValue()); + putMeta(envSecretMetas, command.getKey(), meta); } } else { if (mounted) { fileConfig.put(command.getKey(), command.getValue()); fileConfigMounts.put(command.getKey(), command.getMountPath().trim()); + putMeta(fileConfigMetas, command.getKey(), meta); } else { envConfig.put(command.getKey(), command.getValue()); + putMeta(envConfigMetas, command.getKey(), meta); } } } @@ -137,10 +169,16 @@ public void updateConfigMap(Environment environment, // The env ConfigMap is always present: envFrom references it and it predates this feature. The Secret // and the two .files resources are created on demand and removed once empty so stale keys don't linger. - applyConfigMap(client, namespace, applicationName, envConfig, Map.of(), ownerReference); - applyOrDeleteSecret(client, namespace, applicationName, envSecret, Map.of(), ownerReference); - applyOrDeleteConfigMap(client, namespace, filesName, fileConfig, fileConfigMounts, ownerReference); - applyOrDeleteSecret(client, namespace, filesName, fileSecret, fileSecretMounts, ownerReference); + applyConfigMap(client, namespace, applicationName, envConfig, Map.of(), envConfigMetas, ownerReference); + applyOrDeleteSecret(client, namespace, applicationName, envSecret, Map.of(), envSecretMetas, ownerReference); + applyOrDeleteConfigMap(client, namespace, filesName, fileConfig, fileConfigMounts, fileConfigMetas, ownerReference); + applyOrDeleteSecret(client, namespace, filesName, fileSecret, fileSecretMounts, fileSecretMetas, ownerReference); + } + + private void putMeta(Map metas, String key, ConfigMeta meta) { + if (meta != null) { + metas.put(key, meta); + } } private void applyOrDeleteConfigMap(KubernetesClient client, @@ -148,12 +186,13 @@ private void applyOrDeleteConfigMap(KubernetesClient client, String name, Map data, Map mounts, + Map metas, OwnerReference ownerReference) { if (data.isEmpty()) { client.configMaps().inNamespace(namespace).withName(name).delete(); return; } - applyConfigMap(client, namespace, name, data, mounts, ownerReference); + applyConfigMap(client, namespace, name, data, mounts, metas, ownerReference); } private void applyOrDeleteSecret(KubernetesClient client, @@ -161,12 +200,13 @@ private void applyOrDeleteSecret(KubernetesClient client, String name, Map data, Map mounts, + Map metas, OwnerReference ownerReference) { if (data.isEmpty()) { client.secrets().inNamespace(namespace).withName(name).delete(); return; } - applySecret(client, namespace, name, data, mounts, ownerReference); + applySecret(client, namespace, name, data, mounts, metas, ownerReference); } private void applyConfigMap(KubernetesClient client, @@ -174,6 +214,7 @@ private void applyConfigMap(KubernetesClient client, String name, Map data, Map mounts, + Map metas, OwnerReference ownerReference) { ConfigMapBuilder builder = new ConfigMapBuilder() .withApiVersion("v1") @@ -181,7 +222,7 @@ private void applyConfigMap(KubernetesClient client, .withNewMetadata() .withName(name) .withNamespace(namespace) - .withAnnotations(mountAnnotations(mounts)) + .withAnnotations(annotations(mounts, metas)) .endMetadata() .withData(data); if (ownerReference != null) { @@ -195,6 +236,7 @@ private void applySecret(KubernetesClient client, String name, Map data, Map mounts, + Map metas, OwnerReference ownerReference) { SecretBuilder builder = new SecretBuilder() .withApiVersion("v1") @@ -203,7 +245,7 @@ private void applySecret(KubernetesClient client, .withNewMetadata() .withName(name) .withNamespace(namespace) - .withAnnotations(mountAnnotations(mounts)) + .withAnnotations(annotations(mounts, metas)) .endMetadata() .withStringData(data); if (ownerReference != null) { @@ -212,12 +254,17 @@ private void applySecret(KubernetesClient client, client.secrets().inNamespace(namespace).resource(builder.build()).serverSideApply(); } - private ConfigMapItem buildItem(String key, String value, boolean secret, String mountPath) { + private ConfigMapItem buildItem(String key, String value, boolean secret, String mountPath, ConfigMeta meta) { ConfigMapItem item = new ConfigMapItem(); item.setKey(key); item.setValue(value); item.setSecret(secret); item.setMountPath(mountPath); + if (meta != null) { + item.setGroup(meta.group()); + item.setComment(meta.comment()); + item.setOrder(meta.order()); + } return item; } @@ -235,15 +282,21 @@ private OwnerReference ownerReferenceOf(String applicationName, StatefulSet stat .build(); } - static Map mountAnnotations(Map mounts) { + static Map annotations(Map mounts, Map metas) { Map annotations = new HashMap<>(); - if (mounts.isEmpty()) { - return annotations; + if (!mounts.isEmpty()) { + try { + annotations.put(MOUNT_ANNOTATION, OBJECT_MAPPER.writeValueAsString(mounts)); + } catch (Exception exception) { + log.warn("Failed to serialize mount annotation: {}", exception.getMessage()); + } } - try { - annotations.put(MOUNT_ANNOTATION, OBJECT_MAPPER.writeValueAsString(mounts)); - } catch (Exception exception) { - log.warn("Failed to serialize mount annotation: {}", exception.getMessage()); + if (!metas.isEmpty()) { + try { + annotations.put(CONFIG_META_ANNOTATION, OBJECT_MAPPER.writeValueAsString(metas)); + } catch (Exception exception) { + log.warn("Failed to serialize config meta annotation: {}", exception.getMessage()); + } } return annotations; } @@ -269,6 +322,41 @@ public static Map readMounts(ObjectMeta metadata) { } } + /** + * Per-key UI metadata serialized as {@code key -> {group, comment, order}} in the {@link + * #CONFIG_META_ANNOTATION} annotation. Blank strings are normalized to {@code null}; {@code order} is the + * item's display position, letting the editor persist a manual ordering that the ConfigMap {@code data} + * map (unordered) cannot. A key with no group, no comment, and no order is not stored at all. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ConfigMeta(String group, String comment, Integer order) { + + static ConfigMeta of(String group, String comment, Integer order) { + String normalizedGroup = StringUtils.trimToNull(group); + String normalizedComment = StringUtils.trimToNull(comment); + if (normalizedGroup == null && normalizedComment == null && order == null) { + return null; + } + return new ConfigMeta(normalizedGroup, normalizedComment, order); + } + } + + static Map readConfigMetas(ObjectMeta metadata) { + if (metadata == null || metadata.getAnnotations() == null) { + return Map.of(); + } + String raw = metadata.getAnnotations().get(CONFIG_META_ANNOTATION); + if (StringUtils.isBlank(raw)) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue(raw, new TypeReference>() {}); + } catch (Exception exception) { + log.warn("Failed to parse config meta annotation: {}", exception.getMessage()); + return Map.of(); + } + } + private String decode(String base64Value) { if (base64Value == null) { return ""; diff --git a/src/test/java/com/github/wellch4n/oops/infrastructure/kubernetes/ConfigMetaAnnotationTests.java b/src/test/java/com/github/wellch4n/oops/infrastructure/kubernetes/ConfigMetaAnnotationTests.java new file mode 100644 index 0000000..84e283c --- /dev/null +++ b/src/test/java/com/github/wellch4n/oops/infrastructure/kubernetes/ConfigMetaAnnotationTests.java @@ -0,0 +1,89 @@ +package com.github.wellch4n.oops.infrastructure.kubernetes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.wellch4n.oops.infrastructure.kubernetes.KubernetesConfigMapGateway.ConfigMeta; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ConfigMetaAnnotationTests { + + @Test + void ofNormalizesBlanksToNullAndDropsEmptyMeta() { + assertNull(ConfigMeta.of(" ", "\t", null)); + assertNull(ConfigMeta.of(null, null, null)); + + ConfigMeta groupOnly = ConfigMeta.of(" database ", "", null); + assertEquals("database", groupOnly.group()); + assertNull(groupOnly.comment()); + + // Order alone is enough to keep the meta (drag-reordered items may have no group/comment). + ConfigMeta orderOnly = ConfigMeta.of("", "", 3); + assertNull(orderOnly.group()); + assertEquals(3, orderOnly.order()); + } + + @Test + void annotationsSerializeMetaAndMountsIndependently() { + Map mounts = Map.of("app.yml", "/etc/app/app.yml"); + Map metas = new LinkedHashMap<>(); + metas.put("DB_HOST", new ConfigMeta("database", "primary host", 0)); + + Map annotations = KubernetesConfigMapGateway.annotations(mounts, metas); + + assertTrue(annotations.containsKey(KubernetesConfigMapGateway.MOUNT_ANNOTATION)); + assertTrue(annotations.containsKey(KubernetesConfigMapGateway.CONFIG_META_ANNOTATION)); + } + + @Test + void emptyMapsProduceNoAnnotations() { + Map annotations = KubernetesConfigMapGateway.annotations(Map.of(), Map.of()); + assertTrue(annotations.isEmpty()); + } + + @Test + void metaSurvivesAnnotationRoundTrip() { + Map original = new LinkedHashMap<>(); + original.put("DB_HOST", new ConfigMeta("database", "primary host", 0)); + original.put("DB_PORT", new ConfigMeta("database", null, 1)); + original.put("FLAG", new ConfigMeta(null, "just a note", 2)); + + Map annotations = KubernetesConfigMapGateway.annotations(Map.of(), original); + ObjectMeta metadata = new ObjectMetaBuilder().withAnnotations(annotations).build(); + + Map restored = KubernetesConfigMapGateway.readConfigMetas(metadata); + + assertEquals(original, restored); + } + + @Test + void legacyMetaWithoutOrderStillDeserializes() { + // Meta written before the order field existed must still load, with order left null. + ObjectMeta legacy = new ObjectMetaBuilder() + .withAnnotations(Map.of( + KubernetesConfigMapGateway.CONFIG_META_ANNOTATION, + "{\"DB_HOST\":{\"group\":\"database\",\"comment\":\"host\"}}")) + .build(); + + Map restored = KubernetesConfigMapGateway.readConfigMetas(legacy); + + assertEquals("database", restored.get("DB_HOST").group()); + assertNull(restored.get("DB_HOST").order()); + } + + @Test + void readConfigMetasReturnsEmptyWhenAnnotationMissingOrGarbled() { + assertTrue(KubernetesConfigMapGateway.readConfigMetas(null).isEmpty()); + assertTrue(KubernetesConfigMapGateway.readConfigMetas(new ObjectMetaBuilder().build()).isEmpty()); + + ObjectMeta garbled = new ObjectMetaBuilder() + .withAnnotations(Map.of(KubernetesConfigMapGateway.CONFIG_META_ANNOTATION, "not-json")) + .build(); + assertTrue(KubernetesConfigMapGateway.readConfigMetas(garbled).isEmpty()); + } +} diff --git a/web/app/apps/components/application-config-info.tsx b/web/app/apps/components/application-config-info.tsx index 5e4097c..a21820d 100644 --- a/web/app/apps/components/application-config-info.tsx +++ b/web/app/apps/components/application-config-info.tsx @@ -1,10 +1,24 @@ "use client" import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" -import { useForm, useFieldArray } from "react-hook-form" +import { useForm, useFieldArray, type UseFormReturn } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { Plus, Trash2, Loader2, Upload, Copy, Container, KeyRound } from "lucide-react" +import { Plus, Trash2, Loader2, Upload, Copy, Container, KeyRound, ChevronDown, Search, GripVertical, Download, FileUp, FileDown, HardDrive, FileText, Variable, Info, MessageSquare, Tags } from "lucide-react" import { toast } from "sonner" +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { ApplicationEnvironmentSelector } from "./application-environment-selector" import { @@ -16,14 +30,6 @@ import { } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" import { getApplicationConfigMaps, updateApplicationConfigMaps } from "@/lib/api/applications" import { ApplicationEnvironment, ConfigMap } from "@/lib/api/types" import { ApplicationConfigFormValues, applicationConfigSchema } from "../schema" @@ -35,13 +41,23 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" -import { Checkbox } from "@/components/ui/checkbox" import { EnvImportDialog, parseEnvContent } from "./env-import-dialog" +import { parseYamlConfig, serializeYamlConfig } from "./config-yaml" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { ApplicationTabHandle } from "./application-tab-handle" import { useApplicationEditorTab } from "./use-application-editor-tab" import { ApplicationEditorTabSkeleton } from "./application-editor-skeleton" @@ -76,9 +92,9 @@ function ConfigValueTextarea({ value={value} disabled={disabled} autoComplete="off" - placeholder="value" + placeholder="Value" rows={1} - className={`border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow,filter] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none overflow-hidden${masked && value ? " blur-[4px] hover:blur-none focus:blur-none" : ""}`} + className={`border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-9 w-full rounded-md border bg-transparent px-3 py-1.5 text-base shadow-xs outline-none transition-[color,box-shadow,filter] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none overflow-hidden${masked && value ? " blur-[4px] hover:blur-none focus:blur-none" : ""}`} onChange={(e) => { e.currentTarget.style.height = "auto" e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` @@ -88,6 +104,350 @@ function ConfigValueTextarea({ ) } +// Shared grid template for the header and every row so columns stay aligned across groups. +const CONFIG_GRID = "grid grid-cols-[24px_minmax(0,1fr)_minmax(0,1.5fr)_auto] items-start gap-3" + +// Sentinel tab that shows every item regardless of group. Distinct from the ungrouped bucket (""). +const ALL_GROUPS = "__all__" + +// A single draggable config row. Lives at module scope because it uses the useSortable hook, which cannot +// run inside a render callback. It reads/writes form state by absolute index so reordering and grouping map +// straight back to the field array. +function SortableConfigRow({ + id, + index, + form, + isSecret, + knownGroups, + onRemove, + onSetGroup, + t, +}: { + id: string + index: number + form: UseFormReturn + isSecret: boolean + knownGroups: string[] + onRemove: (index: number) => void + onSetGroup: (index: number, group: string) => void + t: (key: string) => string +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) + const [mountOpen, setMountOpen] = useState(false) + const [commentOpen, setCommentOpen] = useState(false) + const [groupOpen, setGroupOpen] = useState(false) + const [groupFocused, setGroupFocused] = useState(false) + // Dialog drafts: edits stay local until "confirm"; cancel / X / outside-click discards them. + const [mountDraft, setMountDraft] = useState("") + const [commentDraft, setCommentDraft] = useState("") + const [groupDraft, setGroupDraft] = useState("") + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : undefined, + } + + const currentGroup = form.watch(`configMaps.${index}.group`)?.trim() ?? "" + const currentGroupValue = form.watch(`configMaps.${index}.group`) ?? "" + const currentMountPath = form.watch(`configMaps.${index}.mountPath`) + const currentComment = form.watch(`configMaps.${index}.comment`) + const isMounted = form.watch(`configMaps.${index}.mounted`) + + return ( +
+ {/* Drag handle */} + + + {/* Key + env-name warning */} +
+ { + const showEnvNameWarning = + !!field.value && !isMounted && !ENV_NAME_PATTERN.test(field.value) + return ( + + +
+ {/* Leading type indicator: file icon when mounted as a file, variable icon otherwise. */} + + {isMounted ? : } + + + {/* Trailing comment hint: hover the "i" to read the comment. */} + {currentComment?.trim() && ( + + + + + + + + {currentComment} + + + )} +
+
+ {showEnvNameWarning && ( +

{t("apps.config.envNameWarning")}

+ )} + +
+ ) + }} + /> +
+ + {/* Value */} +
+ ( + + + + + + + )} + /> +
+ + {/* Actions: mount / comment / group dialogs + delete. Each dialog edits a local draft; changes are + committed only on "confirm" — closing via X or clicking outside discards them. */} +
+ {/* Mount */} + + + + + {t("apps.config.mountAsFile")} + + + + + {t("apps.config.mountPath")} + +
+ setMountDraft(event.target.value)} + /> +

{t("apps.config.mountPopoverHint")}

+
+ + + + +
+
+ + {/* Comment */} + + + + + {t("apps.config.comment")} + + + + + {t("apps.config.comment")} + +
+