Feat/cluster metadata (#836)

* feat: add cluster metadata key-value store

- Add ClusterMetadata model with ClusterID FK, key, value fields
- Add Metadata []ClusterMetadata relation to Cluster model
- Add CRUD handlers: List, Get, Create, Update, Delete cluster metadata
  - Keys are forced to lowercase on create/update
  - Values preserve case sensitivity
- Add metadata routes under /clusters/{clusterID}/metadata
- Include metadata in ClusterResponse DTO and clusterToDTO mapping
- Add Preload(Metadata) to all cluster queries
- Register ClusterMetadata in AutoMigrate

Closes: internal-GlueOps/issues#302

* feat: include cluster metadata in prepare payload

- Preload cluster Metadata in ClusterPrepareWorker
- Map cluster metadata into mapper.ClusterToDTO response payload

This ensures metadata key-value pairs are injected into the platform JSON payload used by prepare/bootstrap flows.

* feat: add cluster metadata UI section to configure dialog

* feat: simplify cluster metadata to map[string]string in response

* fix: address cluster metadata PR review feedback

Agent-Logs-Url: https://github.com/GlueOps/autoglue/sessions/f767d4b8-ecae-4cde-bb5c-f0845c5a7cdf

Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>

* chore: finalize review feedback updates

Agent-Logs-Url: https://github.com/GlueOps/autoglue/sessions/f767d4b8-ecae-4cde-bb5c-f0845c5a7cdf

Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>

* chore: revert unintended go.sum change

Agent-Logs-Url: https://github.com/GlueOps/autoglue/sessions/f767d4b8-ecae-4cde-bb5c-f0845c5a7cdf

Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>

* fix: add missing go.sum entry for golang.org/x/tools (swag v2 transitive dep)

* feat: add cluster metadata listing and updating functionality

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yesterdaysrebel <256862558+yesterdaysrebel@users.noreply.github.com>
This commit is contained in:
Irfan Paraniya
2026-04-17 12:21:52 +05:30
committed by GitHub
parent 885505dfcc
commit 4eb9da36b0
2 changed files with 107 additions and 11 deletions

View File

@@ -148,6 +148,10 @@ export const clustersApi = {
// --- metadata ---
listClusterMetadata: (clusterID: string) =>
withRefresh(async () => {
return await clusterMetadata.listClusterMetadata({ clusterID })
}),
createClusterMetadata: (clusterID: string, key: string, value: string) =>
withRefresh(async () => {
return await clusterMetadata.createClusterMetadata({
@@ -155,7 +159,14 @@ export const clustersApi = {
createClusterMetadataRequest: { key, value },
})
}),
updateClusterMetadata: (clusterID: string, metadataID: string, value: string) =>
withRefresh(async () => {
return await clusterMetadata.updateClusterMetadata({
clusterID,
metadataID,
updateClusterMetadataRequest: { value },
})
}),
// --- cluster runs / actions ---
listClusterRuns: (clusterID: string) =>
withRefresh(async () => {

View File

@@ -5,10 +5,10 @@ import { dnsApi } from "@/api/dns";
import { loadBalancersApi } from "@/api/loadbalancers";
import { nodePoolsApi } from "@/api/node_pools";
import { serversApi } from "@/api/servers";
import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
import type { DtoActionResponse, DtoClusterMetadataResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Key, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react";
import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Key, Loader2, MapPin, Pencil, Plus, Save, Search, Server, Wrench, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -222,6 +222,8 @@ export const ClustersPage = () => {
const [nodePoolId, setNodePoolId] = useState("")
const [metadataKey, setMetadataKey] = useState("")
const [metadataValue, setMetadataValue] = useState("")
const [editingMetadataId, setEditingMetadataId] = useState<string | null>(null)
const [editingMetadataValue, setEditingMetadataValue] = useState("")
const [kubeconfigText, setKubeconfigText] = useState("")
const [busyKey, setBusyKey] = useState<string | null>(null)
@@ -285,6 +287,13 @@ export const ClustersPage = () => {
},
})
const metadataQ = useQuery({
queryKey: ["cluster-metadata", configCluster?.id],
enabled: !!configCluster?.id,
queryFn: async () =>
asArray<DtoClusterMetadataResponse>(await clustersApi.listClusterMetadata(configCluster!.id!)),
})
const actionLabelByTarget = useMemo(() => {
const m = new Map<string, string>()
;(actionsQ.data ?? []).forEach((a) => {
@@ -412,6 +421,8 @@ export const ClustersPage = () => {
useEffect(() => {
setMetadataKey("")
setMetadataValue("")
setEditingMetadataId(null)
setEditingMetadataValue("")
if (!configCluster) {
setCaptainDomainId("")
@@ -441,6 +452,7 @@ export const ClustersPage = () => {
setConfigCluster(updated)
await qc.invalidateQueries({ queryKey: ["clusters"] })
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster.id] })
await qc.invalidateQueries({ queryKey: ["cluster-metadata", configCluster.id] })
} catch {
// ignore
}
@@ -673,6 +685,37 @@ export const ClustersPage = () => {
}
}
async function handleUpdateMetadata() {
if (!configCluster?.id || !editingMetadataId) return
if (!editingMetadataValue.trim()) return toast.error("Value is required")
setBusyKey(`metadata:${editingMetadataId}`)
try {
await clustersApi.updateClusterMetadata(
configCluster.id,
editingMetadataId,
editingMetadataValue.trim(),
)
toast.success("Metadata updated.")
setEditingMetadataId(null)
setEditingMetadataValue("")
await refreshConfigCluster()
} catch (err: any) {
toast.error(err?.message ?? "Failed to update metadata.")
} finally {
setBusyKey(null)
}
}
function beginMetadataEdit(metadata: DtoClusterMetadataResponse) {
if (!metadata.id) return
setEditingMetadataId(metadata.id)
setEditingMetadataValue(metadata.value ?? "")
}
function cancelMetadataEdit() {
setEditingMetadataId(null)
setEditingMetadataValue("")
}
if (clustersQ.isLoading) return <div className="p-6">Loading clusters</div>
@@ -1552,18 +1595,60 @@ export const ClustersPage = () => {
<div className="mt-3 space-y-1">
<Label className="text-xs">Stored Metadata</Label>
{configCluster.metadata && Object.keys(configCluster.metadata).length > 0 ? (
{metadataQ.isLoading ? (
<p className="text-muted-foreground mt-1 text-xs">Loading metadata</p>
) : (metadataQ.data ?? []).length > 0 ? (
<div className="divide-border mt-1 rounded-md border">
{Object.entries(configCluster.metadata).map(([key, value]) => (
{(metadataQ.data ?? []).map((metadata) => (
<div
key={key}
key={metadata.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-xs"
>
<div className="flex flex-col min-w-0">
<code className="font-mono font-medium">{key}</code>
<code className="text-muted-foreground font-mono text-xs truncate">
{value}
</code>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<code className="font-mono font-medium">{metadata.key}</code>
{editingMetadataId === metadata.id ? (
<Input
value={editingMetadataValue}
onChange={(e) => setEditingMetadataValue(e.target.value)}
className="h-8 text-xs"
/>
) : (
<code className="text-muted-foreground font-mono text-xs break-all">
{metadata.value}
</code>
)}
</div>
<div className="flex items-center gap-2">
{editingMetadataId === metadata.id ? (
<>
<Button
size="sm"
onClick={handleUpdateMetadata}
disabled={
isBusy(`metadata:${metadata.id}`) || !editingMetadataValue.trim()
}
>
<Save className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelMetadataEdit}
disabled={isBusy(`metadata:${metadata.id}`)}
>
<X className="h-3 w-3" />
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => beginMetadataEdit(metadata)}
disabled={!!editingMetadataId}
>
<Pencil className="h-3 w-3" />
</Button>
)}
</div>
</div>
))}