Files
ocdp-go/frontend/src/features/artifact/instances/components/ModifyModal.tsx
Ivan087 33ddaf97db fix: scale replicas in response, K8s metrics client, quota precheck, auth tests
- 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
2026-05-20 16:56:29 +08:00

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&#10;nested:&#10; 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>;
};