Skip to content

Commit 6cbff45

Browse files
committed
web: add Values tab to HelmRelease dashboard
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
1 parent 2b56164 commit 6cbff45

15 files changed

Lines changed: 336 additions & 12 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@ web-config.yaml
3636

3737
# CLI (normally how Claude Code builds it).
3838
/cli
39+
40+
# MCP config file.
41+
.mcp.json

api/v1/webconfig_types.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,14 @@ type WebConfigSpec struct {
107107
Authentication *AuthenticationSpec `json:"authentication"`
108108
}
109109

110+
// IsAuthEnabled checks if authentication is enabled.
111+
func (c *WebConfigSpec) IsAuthEnabled() bool {
112+
return c != nil && c.Authentication != nil
113+
}
114+
110115
// UserActionsEnabled checks if user actions are enabled.
111116
func (c *WebConfigSpec) UserActionsEnabled() bool {
112-
return c != nil && c.Authentication != nil
117+
return c.IsAuthEnabled()
113118
}
114119

115120
// AuthenticationSpec holds the Flux Status Page authentication configuration.

internal/web/helm_values.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,53 @@ import (
1313
"github.com/fluxcd/pkg/runtime/transform"
1414
corev1 "k8s.io/api/core/v1"
1515
"k8s.io/apimachinery/pkg/api/errors"
16+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1617
"k8s.io/apimachinery/pkg/types"
1718
"sigs.k8s.io/controller-runtime/pkg/client"
1819
"sigs.k8s.io/yaml"
1920
)
2021

22+
// getHelmValues extracts valuesFrom references and inline values from a
23+
// HelmRelease unstructured object, resolves them via helmValuesFromReferences,
24+
// and returns the merged values map.
25+
func (h *Handler) getHelmValues(ctx context.Context, obj unstructured.Unstructured) (map[string]any, error) {
26+
var refs []meta.ValuesReference
27+
if valuesFrom, found, _ := unstructured.NestedSlice(obj.Object, "spec", "valuesFrom"); found {
28+
for _, vf := range valuesFrom {
29+
m, ok := vf.(map[string]any)
30+
if !ok {
31+
continue
32+
}
33+
ref := meta.ValuesReference{}
34+
if v, ok := m["kind"].(string); ok {
35+
ref.Kind = v
36+
}
37+
if v, ok := m["name"].(string); ok {
38+
ref.Name = v
39+
}
40+
if v, ok := m["valuesKey"].(string); ok {
41+
ref.ValuesKey = v
42+
}
43+
if v, ok := m["targetPath"].(string); ok {
44+
ref.TargetPath = v
45+
}
46+
if v, ok := m["optional"].(bool); ok {
47+
ref.Optional = v
48+
}
49+
refs = append(refs, ref)
50+
}
51+
}
52+
53+
inlineValues, _, _ := unstructured.NestedMap(obj.Object, "spec", "values")
54+
55+
if len(refs) == 0 && len(inlineValues) == 0 {
56+
return nil, nil
57+
}
58+
59+
return helmValuesFromReferences(ctx, h.kubeClient.GetClient(ctx),
60+
obj.GetNamespace(), inlineValues, refs...)
61+
}
62+
2163
// helmValuesFromReferences resolves Helm release values from ConfigMap/Secret
2264
// references, merging them in the order given. If provided, the values map is
2365
// merged last overwriting values from references, unless a reference has a

internal/web/resource.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,37 @@ func (h *Handler) GetResource(ctx context.Context, kind, name, namespace string)
191191
}
192192
}
193193

194+
// Get the final values of the resource if it's a HelmRelease and inject into status.
195+
// Since values can come from Kubernetes Secrets, we fetch them only
196+
// if authentication is enabled to enforce RBAC via impersonation.
197+
if h.conf.IsAuthEnabled() && gvk.Kind == fluxcdv1.FluxHelmReleaseKind {
198+
var valuesError string
199+
helmValues, err := h.getHelmValues(ctx, *obj)
200+
if err != nil {
201+
if !errors.IsForbidden(err) {
202+
return nil, err
203+
}
204+
log.FromContext(ctx).Error(err, "user does not have access to Helm values references")
205+
perms := user.Permissions(ctx)
206+
valuesError = fmt.Sprintf("You do not have access to the values references of this resource. "+
207+
"Contact your administrator if you believe this is an error. "+
208+
"User: %s, Groups: [%s]",
209+
perms.Username, strings.Join(perms.Groups, ", "))
210+
}
211+
212+
if len(helmValues) > 0 {
213+
if err := unstructured.SetNestedMap(obj.Object, helmValues, "status", "helmValues"); err != nil {
214+
return nil, fmt.Errorf("unable to set helmValues in status: %w", err)
215+
}
216+
}
217+
218+
if valuesError != "" {
219+
if err := unstructured.SetNestedField(obj.Object, valuesError, "status", "helmValuesError"); err != nil {
220+
return nil, fmt.Errorf("unable to set helmValuesError in status: %w", err)
221+
}
222+
}
223+
}
224+
194225
// List which actions the user can perform on this resource.
195226
if h.conf.UserActionsEnabled() {
196227
var actions []string

web/src/components/dashboards/resource/ArtifactPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function ArtifactPanel({ resourceData }) {
100100
<DashboardPanel title="Artifact" id="artifact-panel">
101101
{/* Tab Navigation */}
102102
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
103-
<nav class="flex space-x-4">
103+
<nav class="flex space-x-4 overflow-x-auto">
104104
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
105105
<span class="sm:hidden">Info</span>
106106
<span class="hidden sm:inline">Overview</span>

web/src/components/dashboards/resource/ExportedInputsPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function ExportedInputsPanel({ resourceData }) {
7474
<DashboardPanel title="Exported Inputs" id="exported-inputs-panel">
7575
{/* Tab Navigation */}
7676
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
77-
<nav class="flex space-x-4">
77+
<nav class="flex space-x-4 overflow-x-auto">
7878
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
7979
<span class="sm:hidden">Info</span>
8080
<span class="hidden sm:inline">Overview</span>

web/src/components/dashboards/resource/InputsPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function InputsPanel({ resourceData, namespace }) {
116116
<DashboardPanel title="Inputs" id="inputs-panel">
117117
{/* Tab Navigation */}
118118
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
119-
<nav class="flex space-x-4">
119+
<nav class="flex space-x-4 overflow-x-auto">
120120
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
121121
<span class="sm:hidden">Info</span>
122122
<span class="hidden sm:inline">Overview</span>

web/src/components/dashboards/resource/InventoryPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export function InventoryPanel({ resourceData, onNavigate }) {
126126
<DashboardPanel title="Managed Objects" id="inventory-panel">
127127
{/* Tab Navigation */}
128128
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
129-
<nav class="flex space-x-4">
129+
<nav class="flex space-x-4 overflow-x-auto">
130130
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
131131
<span class="sm:hidden">Info</span>
132132
<span class="hidden sm:inline">Overview</span>

web/src/components/dashboards/resource/ReconcilerPanel.jsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { FluxOperatorIcon } from '../../layout/Icons'
1313
import { useHashTab } from '../../../utils/hash'
1414

1515
// Valid tabs for the ReconcilerPanel
16-
const RECONCILER_TABS = ['overview', 'history', 'events', 'spec', 'status']
16+
const RECONCILER_TABS = ['overview', 'history', 'events', 'values', 'spec', 'status']
1717

1818
export function ReconcilerPanel({ kind, name, namespace, resourceData }) {
1919
// Tab state synced with URL hash (e.g., #reconciler-events)
@@ -111,6 +111,18 @@ export function ReconcilerPanel({ kind, name, namespace, resourceData }) {
111111
}
112112
}, [resourceData])
113113

114+
const valuesYaml = useMemo(() => {
115+
if (kind !== 'HelmRelease' || !resourceData) return null
116+
if (resourceData.status?.helmValues) return resourceData.status.helmValues
117+
if (resourceData.spec?.values) return resourceData.spec.values
118+
return null
119+
}, [resourceData, kind])
120+
121+
const valuesError = useMemo(() => {
122+
if (kind !== 'HelmRelease' || !resourceData) return null
123+
return resourceData.status?.helmValuesError || null
124+
}, [resourceData, kind])
125+
114126
const statusYaml = useMemo(() => {
115127
if (!resourceData?.status) return null
116128
return {
@@ -183,7 +195,7 @@ export function ReconcilerPanel({ kind, name, namespace, resourceData }) {
183195
<DashboardPanel title="Reconciler" id="reconciler-panel">
184196
{/* Tab Navigation */}
185197
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
186-
<nav class="flex space-x-4">
198+
<nav class="flex space-x-4 overflow-x-auto">
187199
<TabButton active={reconcilerTab === 'overview'} onClick={() => setReconcilerTab('overview')}>
188200
<span class="sm:hidden">Info</span>
189201
<span class="hidden sm:inline">Overview</span>
@@ -196,6 +208,11 @@ export function ReconcilerPanel({ kind, name, namespace, resourceData }) {
196208
<TabButton active={reconcilerTab === 'events'} onClick={() => setReconcilerTab('events')}>
197209
Events
198210
</TabButton>
211+
{kind === 'HelmRelease' && (valuesError || valuesYaml) && (
212+
<TabButton active={reconcilerTab === 'values'} onClick={() => setReconcilerTab('values')}>
213+
Values
214+
</TabButton>
215+
)}
199216
<TabButton active={reconcilerTab === 'spec'} onClick={() => setReconcilerTab('spec')}>
200217
<span class="sm:hidden">Spec</span>
201218
<span class="hidden sm:inline">Specification</span>
@@ -292,6 +309,19 @@ export function ReconcilerPanel({ kind, name, namespace, resourceData }) {
292309
/>
293310
)}
294311

312+
{/* Values Tab */}
313+
{reconcilerTab === 'values' && kind === 'HelmRelease' && (
314+
<div>
315+
{valuesError ? (
316+
<div class="text-sm text-red-600 dark:text-red-400">
317+
<pre class="whitespace-pre-wrap break-all font-sans">{valuesError}</pre>
318+
</div>
319+
) : valuesYaml ? (
320+
<YamlBlock data={valuesYaml} />
321+
) : null}
322+
</div>
323+
)}
324+
295325
{reconcilerTab === 'spec' && <YamlBlock data={specYaml} />}
296326
{reconcilerTab === 'status' && <YamlBlock data={statusYaml} />}
297327

0 commit comments

Comments
 (0)