feat: complete E2E deployment flow with storage layered config and values template versioning
- Instance deployment: charts browser, deploy modal, instances list - Values Template version management (create/history/rollback) - Storage layered config (cluster > workspace > shared priority) - Cluster credential decryptIfNeeded for mixed encrypted/plaintext kubeconfig - YAML syntax validation (client-side + server-side warning) - Frontend: charts, instances, storage, templates, admin pages - Backend: storage service, instance service, cluster service, helm client - Multi-Tenant Kubeconfig.md: added by user
This commit is contained in:
@ -19,12 +19,12 @@ interface RegistryDTO {
|
||||
interface CreateInstanceRequest {
|
||||
name: string;
|
||||
namespace: string;
|
||||
registryId: string;
|
||||
repository: string;
|
||||
chart: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
values_yaml?: string;
|
||||
registry_id?: string;
|
||||
valuesYaml?: string;
|
||||
}
|
||||
|
||||
interface ClusterDTO {
|
||||
@ -68,6 +68,7 @@ export default function ChartsPage() {
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<Artifact | null>(null);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
const [deployError, setDeployError] = useState<string | null>(null);
|
||||
const [valuesYamlError, setValuesYamlError] = useState<string | null>(null);
|
||||
const [deployForm, setDeployForm] = useState({
|
||||
name: '',
|
||||
namespace: 'default',
|
||||
@ -136,21 +137,30 @@ export default function ChartsPage() {
|
||||
const handleStorageChange = (storageId: string) => {
|
||||
const storage = storages.find(s => s.id === storageId);
|
||||
if (storage) {
|
||||
setDeployForm(prev => ({ ...prev, selectedStorageId: storageId }));
|
||||
// Merge storage config into values (simple merge for NFS)
|
||||
try {
|
||||
const storageConfig = `persistence:
|
||||
// Merge storage config into values using proper persistence format
|
||||
let storageConfig = '';
|
||||
if (storage.type === 'nfs') {
|
||||
// For NFS, use the storage name as storageClass reference
|
||||
storageConfig = `persistence:
|
||||
enabled: true
|
||||
storageClass: "${storage.name}"
|
||||
existingClaim: ""
|
||||
mountOptions:
|
||||
- hard
|
||||
- nfsvers=4.1
|
||||
`;
|
||||
} else {
|
||||
storageConfig = `persistence:
|
||||
enabled: true
|
||||
storageClass: "${storage.type}"
|
||||
existingClaim: ""
|
||||
`;
|
||||
setDeployForm(prev => ({
|
||||
...prev,
|
||||
selectedStorageId: storageId,
|
||||
valuesYaml: prev.valuesYaml + '\n' + storageConfig
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to merge storage config:', e);
|
||||
}
|
||||
setDeployForm(prev => ({
|
||||
...prev,
|
||||
selectedStorageId: storageId,
|
||||
valuesYaml: prev.valuesYaml ? prev.valuesYaml + '\n' + storageConfig : storageConfig
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -176,6 +186,17 @@ export default function ChartsPage() {
|
||||
// Filter to only show chart type artifacts
|
||||
const allArtifacts = Array.isArray(response.data) ? response.data : [];
|
||||
const chartArtifacts = allArtifacts.filter((a: Artifact) => a.type === 'chart');
|
||||
// Sort by semver descending so index 0 = latest (most recent version)
|
||||
chartArtifacts.sort((a, b) => {
|
||||
const pa = a.tag.split('.').map(Number);
|
||||
const pb = b.tag.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const na = pa[i] || 0;
|
||||
const nb = pb[i] || 0;
|
||||
if (na !== nb) return nb - na;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setArtifacts(chartArtifacts);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch artifacts:', error);
|
||||
@ -226,9 +247,9 @@ export default function ChartsPage() {
|
||||
repository: selectedRepo!,
|
||||
chart: selectedRepo!.split('/').pop() || selectedRepo!,
|
||||
version: selectedArtifact.tag,
|
||||
registry_id: selectedRegistry?.id,
|
||||
registryId: selectedRegistry?.id || '',
|
||||
description: deployForm.description,
|
||||
values_yaml: deployForm.valuesYaml || undefined,
|
||||
valuesYaml: deployForm.valuesYaml || undefined,
|
||||
};
|
||||
|
||||
await instanceApi.create(deployForm.clusterId, request);
|
||||
@ -260,7 +281,7 @@ export default function ChartsPage() {
|
||||
const openDeployModal = (artifact: Artifact) => {
|
||||
setSelectedArtifact(artifact);
|
||||
setDeployForm({
|
||||
name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(),
|
||||
name: artifact.repositoryName.split('/').pop()!.toLowerCase(),
|
||||
namespace: 'default',
|
||||
clusterId: clusters[0]?.id || '',
|
||||
description: '',
|
||||
@ -459,7 +480,7 @@ export default function ChartsPage() {
|
||||
{/* Deploy Modal */}
|
||||
{showDeployModal && selectedArtifact && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-lg border border-[var(--border)]">
|
||||
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-lg border border-[var(--border)] max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-[var(--foreground)]">Deploy Chart</h2>
|
||||
<button
|
||||
@ -586,13 +607,42 @@ export default function ChartsPage() {
|
||||
</label>
|
||||
<textarea
|
||||
value={deployForm.valuesYaml}
|
||||
onChange={(e) => setDeployForm({ ...deployForm, valuesYaml: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] font-mono text-sm"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setDeployForm({ ...deployForm, valuesYaml: val });
|
||||
// Client-side YAML syntax validation
|
||||
if (val.trim()) {
|
||||
try {
|
||||
// Quick line-by-line check for common mistakes
|
||||
const lines = val.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=') && !trimmed.includes(':')) {
|
||||
const key = trimmed.split('=')[0].trim();
|
||||
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
setValuesYamlError(`Syntax error: use ":" instead of "=" for key "${key}" (YAML uses colons, not equals signs)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setValuesYamlError(null);
|
||||
} catch {}
|
||||
} else {
|
||||
setValuesYamlError(null);
|
||||
}
|
||||
}}
|
||||
className={`w-full px-3 py-2 bg-[var(--background)] border rounded-lg text-[var(--foreground)] font-mono text-sm ${valuesYamlError ? 'border-red-500' : 'border-[var(--border)]'}`}
|
||||
rows={4}
|
||||
placeholder="# Optional: Override chart values replicaCount: 2 image: tag: latest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{valuesYamlError && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
|
||||
{valuesYamlError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deployError && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
|
||||
{deployError}
|
||||
@ -613,7 +663,7 @@ export default function ChartsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying || !deployForm.name || !deployForm.clusterId}
|
||||
disabled={isDeploying || !deployForm.name || !deployForm.clusterId || !!valuesYamlError}
|
||||
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isDeploying && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
|
||||
Reference in New Issue
Block a user