- Add GetMetrics method to MetricsClient interface and implement cluster metrics API - Add QuotaPrecheck service for validating resource quotas before deployment - Add auth DTO with role/permission models and auth handler tests - Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics - Update workspace handler with GetWorkspace endpoint and shared-user list - Fix monitoring handler to use correct service method name - Add tail_lines fallback in instance handler for snake_case query params - Update nginx config for SSE log streaming support (no buffering) - Add comprehensive test coverage: auth_service_test, auth_handler_test, auth_dto_test, metrics_client_test, quota_precheck_test - Update error messages for quota validation and instance operations - ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit - InstanceCard: correctly disable scale-minus when replicas <= 0 - SidebarLayout: add hover transition for sidebar items - Update todo.md and lessons.md with latest fixes
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
/**
|
|
* Modify Modal Component
|
|
* Modal for modifying an instance configuration
|
|
* Supports Values Schema for dynamic form generation
|
|
*/
|
|
import React, { useState, useEffect } from "react";
|
|
import { Settings } from "lucide-react";
|
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
import type { InstanceResponse, UpdateInstanceRequest } from "@/api";
|
|
import { getInstance, getInstanceValuesDiff } from "@/api";
|
|
import {
|
|
Modal,
|
|
Button,
|
|
FormField,
|
|
Input,
|
|
Textarea,
|
|
ErrorState,
|
|
LoadingState,
|
|
Badge,
|
|
} from "@/shared/components";
|
|
|
|
interface ModifyModalProps {
|
|
instance: InstanceResponse;
|
|
onClose: () => void;
|
|
onConfirm: (clusterId: string, instanceId: string, data: UpdateInstanceRequest) => Promise<void>;
|
|
}
|
|
|
|
export const ModifyModal: React.FC<ModifyModalProps> = ({
|
|
instance,
|
|
onClose,
|
|
onConfirm,
|
|
}) => {
|
|
const [tag, setTag] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [valuesYaml, setValuesYaml] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [modifiedKeys, setModifiedKeys] = useState<string[]>([]);
|
|
|
|
// Values Diff support
|
|
const [showDiff, setShowDiff] = useState(false);
|
|
const [loadingDiff] = useState(false);
|
|
const [diffData, setDiffData] = useState<{
|
|
current: Record<string, any>;
|
|
defaults: Record<string, any>;
|
|
} | null>(null);
|
|
const [diffError] = useState<string | null>(null);
|
|
|
|
// Fetch full Helm values (via values-diff API) and instance detail
|
|
useEffect(() => {
|
|
setTag(instance.version || "");
|
|
setDescription("");
|
|
|
|
const loadData = async () => {
|
|
if (instance.clusterId && instance.id) {
|
|
// Load values diff first — gives us the full current Helm values
|
|
try {
|
|
const data = await getInstanceValuesDiff(instance.clusterId, instance.id);
|
|
if (data?.current && Object.keys(data.current).length > 0) {
|
|
const currentYaml = stringifyYaml(data.current, { lineWidth: 0 });
|
|
setValuesYaml(currentYaml);
|
|
setDiffData({ current: data.current, defaults: data.defaults ?? {} });
|
|
}
|
|
} catch (err) {
|
|
console.error('[ModifyModal] Failed to load values diff:', err);
|
|
// Fallback: try instance detail
|
|
try {
|
|
const detail = await getInstance({ clusterId: instance.clusterId, instanceId: instance.id });
|
|
if (detail.values && Object.keys(detail.values).length > 0) {
|
|
const y = stringifyYaml(detail.values, { lineWidth: 0 });
|
|
setValuesYaml(y);
|
|
}
|
|
} catch (err2) {
|
|
console.error('[ModifyModal] Failed to load instance detail:', err2);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
loadData();
|
|
}, [instance]);
|
|
|
|
// Recompute modified keys when valuesYaml or diffData changes
|
|
useEffect(() => {
|
|
if (!diffData?.defaults || !valuesYaml) return;
|
|
try {
|
|
const current = parseYaml(valuesYaml);
|
|
const defaults = diffData.defaults;
|
|
const changed: string[] = [];
|
|
const walkKeys = (curr: any, def: any, prefix: string) => {
|
|
if (curr === null || curr === undefined) return;
|
|
if (typeof curr !== 'object') return;
|
|
for (const key of Object.keys(curr)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (JSON.stringify(curr[key]) !== JSON.stringify(def?.[key])) {
|
|
changed.push(fullKey);
|
|
}
|
|
if (typeof curr[key] === 'object' && curr[key] !== null && !Array.isArray(curr[key])) {
|
|
walkKeys(curr[key], def?.[key] ?? {}, fullKey);
|
|
}
|
|
}
|
|
};
|
|
walkKeys(current, defaults, '');
|
|
setModifiedKeys(changed);
|
|
} catch { /* ignore parse errors */ }
|
|
}, [valuesYaml, diffData]);
|
|
|
|
|
|
const applyDefaults = () => {
|
|
if (!diffData?.defaults) return;
|
|
setValuesYaml(stringifyYaml(diffData.defaults, { lineWidth: 0 }));
|
|
};
|
|
|
|
/**
|
|
* Render a values object as YAML lines, bolding keys that differ from defaults.
|
|
*/
|
|
const renderDiffValues = (
|
|
values: Record<string, any>,
|
|
compare: Record<string, any>,
|
|
): React.ReactNode => {
|
|
const yaml = stringifyYaml(values);
|
|
const lines = yaml.split("\n");
|
|
return lines.map((line, i) => {
|
|
// Extract the key name from a YAML line
|
|
const keyMatch = line.match(/^(\s*)([a-zA-Z_][\w-]*)\s*:/);
|
|
if (keyMatch) {
|
|
const key = keyMatch[2];
|
|
const keyChanged =
|
|
compare[key] !== undefined &&
|
|
JSON.stringify(values[key]) !== JSON.stringify(compare[key]);
|
|
if (keyChanged) {
|
|
return (
|
|
<span key={i} className="block">
|
|
{keyMatch[1]}<strong className="text-amber-600 dark:text-amber-400">{key}</strong>:{line.slice(keyMatch[0].length)}
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
return <span key={i} className="block">{line}</span>;
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
if (valuesYaml.trim()) {
|
|
parseValuesYaml(valuesYaml);
|
|
}
|
|
const payload: UpdateInstanceRequest = {
|
|
version: tag && tag !== instance.version ? tag : undefined,
|
|
description: description.trim() || undefined,
|
|
valuesYaml: valuesYaml.trim() || undefined,
|
|
};
|
|
|
|
if (!instance.clusterId || !instance.id) {
|
|
setError("Instance identifier is missing");
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
await onConfirm(instance.clusterId, instance.id, payload);
|
|
onClose();
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && err.message.includes("YAML")) {
|
|
setError(err.message);
|
|
} else {
|
|
setError((err as Error).message || "Failed to modify instance");
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={true}
|
|
onClose={onClose}
|
|
title={`Modify Instance - ${instance.name || "Unnamed"}`}
|
|
icon={Settings}
|
|
iconColor="text-blue-600"
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={onClose}
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
icon={Settings}
|
|
loading={loading}
|
|
onClick={handleSubmit}
|
|
>
|
|
{loading ? "Modifying..." : "Modify"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<ErrorState message={error} title="Modification Failed" />
|
|
)}
|
|
|
|
{/* Current Info */}
|
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-2">
|
|
<p className="text-sm text-slate-700">
|
|
<span className="font-medium text-slate-900">Current Version:</span> {instance.version || "N/A"}
|
|
</p>
|
|
<p className="text-sm text-slate-700">
|
|
<span className="font-medium text-slate-900">Cluster:</span> {instance.clusterId || "N/A"}
|
|
</p>
|
|
<p className="text-sm text-slate-700">
|
|
<span className="font-medium text-slate-900">Repository:</span> {instance.repository || "N/A"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tag */}
|
|
<FormField
|
|
label="Version Tag"
|
|
required
|
|
help="Leave unchanged to keep current version"
|
|
>
|
|
<Input
|
|
type="text"
|
|
value={tag}
|
|
onChange={(e) => setTag(e.target.value)}
|
|
placeholder="e.g., v1.0.0"
|
|
required
|
|
/>
|
|
</FormField>
|
|
|
|
{/* Description */}
|
|
<FormField label="Description">
|
|
<Input
|
|
type="text"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Modification description"
|
|
/>
|
|
</FormField>
|
|
|
|
{/* Current Values — directly editable as YAML */}
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Configuration Values
|
|
</label>
|
|
<p className="text-xs text-slate-500">
|
|
Editing current deployed values. The full YAML is submitted so nested chart values stay intact.
|
|
</p>
|
|
{modifiedKeys.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
<span className="text-slate-500">Modified:</span>
|
|
{modifiedKeys.map((k) => (
|
|
<span key={k} className="px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-mono font-medium">
|
|
{k}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Textarea
|
|
value={valuesYaml}
|
|
onChange={(e) => setValuesYaml(e.target.value)}
|
|
rows={14}
|
|
placeholder="key: value nested: key: value"
|
|
className="font-mono text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Values Diff Section */}
|
|
{instance.clusterId && instance.id && (
|
|
<div className="border-t border-slate-200 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowDiff(!showDiff);
|
|
}}
|
|
className="flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-700 transition-colors"
|
|
>
|
|
<span>{showDiff ? "Hide" : "Show"} Values Diff</span>
|
|
<span className={`text-xs transition-transform ${showDiff ? "rotate-180" : ""}`}>▼</span>
|
|
</button>
|
|
|
|
{showDiff && (
|
|
<div className="mt-3 space-y-3">
|
|
{loadingDiff && (
|
|
<LoadingState message="Loading values diff..." />
|
|
)}
|
|
{diffError && (
|
|
<ErrorState title="Diff Error" message={diffError} />
|
|
)}
|
|
{diffData && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Current Values */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
Current
|
|
</span>
|
|
<Badge variant="default" size="sm">deployed</Badge>
|
|
</div>
|
|
<pre className="text-xs font-mono bg-slate-50 border border-slate-200 rounded-lg p-3 max-h-64 overflow-auto whitespace-pre text-slate-700">
|
|
{renderDiffValues(diffData.current, diffData.defaults)}
|
|
</pre>
|
|
</div>
|
|
{/* Default Values */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
Defaults
|
|
</span>
|
|
<Badge variant="info" size="sm">chart</Badge>
|
|
</div>
|
|
<pre className="text-xs font-mono bg-slate-50 border border-slate-200 rounded-lg p-3 max-h-64 overflow-auto whitespace-pre text-slate-500">
|
|
{renderDiffValues(diffData.defaults, diffData.current)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
{/* Legend */}
|
|
<p className="text-xs text-slate-500">
|
|
<strong className="text-amber-600 dark:text-amber-400">Bold amber keys</strong> differ between current and default values.
|
|
</p>
|
|
{/* Use Defaults Button */}
|
|
<button
|
|
type="button"
|
|
onClick={applyDefaults}
|
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 border border-indigo-200 rounded-lg px-3 py-1.5 transition-all"
|
|
title="Replace current values with chart defaults"
|
|
>
|
|
<Settings className="w-3.5 h-3.5" />
|
|
Use Defaults
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-slate-500">
|
|
Update applies the selected chart version and values override. Resource readiness is tracked from the instance list after submit.
|
|
</p>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
const parseValuesYaml = (source: string): Record<string, any> => {
|
|
const parsed = parseYaml(source);
|
|
if (parsed == null) {
|
|
return {};
|
|
}
|
|
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("Values YAML must be an object");
|
|
}
|
|
return parsed as Record<string, any>;
|
|
};
|