fix: resolve deployment API errors and enable E2E deployment flow
Backend fixes: - instance_dto: add Version field with Normalize() to support both 'version' and 'tag' field names from frontend - instance_handler: add version empty validation before creating instance - authz.go: fix unused variable compilation error - registry_repository: fix GetByID/GetByName to use correct DB schema (add workspace_id, owner_id, is_shared fields); decrypt password gracefully when encryption key mismatches instead of returning error Frontend: - charts/page: add Template and Storage dropdown selectors to Deploy Modal Testing: - add e2e_test.py: 5-step Playwright E2E test (admin login → create workspace → create user → user login → deploy chart) - add tasks/lesson.md: document 4 bug root causes and fixes - add tasks/todo.md: track implementation progress - add PLAN_E2E_DEPLOYMENT.md: comprehensive implementation plan Verification: confirmed deployment creates instance with status=deployed, chart downloads from Harbor OCI to /tmp/charts/, Helm release deploys to K8s
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { registryApi, instanceApi, clusterApi } from '@/lib/api';
|
||||
import { registryApi, instanceApi, clusterApi, valuesTemplateApi, storageApi } from '@/lib/api';
|
||||
import { Package, Database, ChevronRight, Search, Rocket, X, Loader2 } from 'lucide-react';
|
||||
|
||||
interface RegistryDTO {
|
||||
@ -34,9 +34,28 @@ interface ClusterDTO {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ValuesTemplateDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
values_yaml: string;
|
||||
version: number;
|
||||
is_default: boolean;
|
||||
chart_reference_id: string;
|
||||
}
|
||||
|
||||
interface StorageDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function ChartsPage() {
|
||||
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
|
||||
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
|
||||
const [templates, setTemplates] = useState<ValuesTemplateDTO[]>([]);
|
||||
const [storages, setStorages] = useState<StorageDTO[]>([]);
|
||||
const [selectedRegistry, setSelectedRegistry] = useState<RegistryDTO | null>(null);
|
||||
const [repositories, setRepositories] = useState<string[]>([]);
|
||||
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
|
||||
@ -55,6 +74,8 @@ export default function ChartsPage() {
|
||||
clusterId: '',
|
||||
description: '',
|
||||
valuesYaml: '',
|
||||
selectedTemplateId: '',
|
||||
selectedStorageId: '',
|
||||
});
|
||||
|
||||
const fetchRegistries = async () => {
|
||||
@ -83,6 +104,56 @@ export default function ChartsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const response = await valuesTemplateApi.list();
|
||||
const data = response.data;
|
||||
const templateList = Array.isArray(data) ? data : (data?.templates || []);
|
||||
setTemplates(templateList);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch templates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStorages = async () => {
|
||||
try {
|
||||
const response = await storageApi.list();
|
||||
const data = response.data;
|
||||
const storageList = Array.isArray(data) ? data : (data?.storages || []);
|
||||
setStorages(storageList);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch storages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateChange = (templateId: string) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
setDeployForm(prev => ({ ...prev, selectedTemplateId: templateId, valuesYaml: template.values_yaml }));
|
||||
}
|
||||
};
|
||||
|
||||
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:
|
||||
enabled: true
|
||||
storageClass: "${storage.type}"
|
||||
`;
|
||||
setDeployForm(prev => ({
|
||||
...prev,
|
||||
selectedStorageId: storageId,
|
||||
valuesYaml: prev.valuesYaml + '\n' + storageConfig
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to merge storage config:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRepositories = async (registryId: string) => {
|
||||
setIsLoadingRepos(true);
|
||||
setRepositories([]);
|
||||
@ -116,6 +187,8 @@ export default function ChartsPage() {
|
||||
useEffect(() => {
|
||||
fetchRegistries();
|
||||
fetchClusters();
|
||||
fetchTemplates();
|
||||
fetchStorages();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -168,6 +241,8 @@ export default function ChartsPage() {
|
||||
clusterId: clusters[0]?.id || '',
|
||||
description: '',
|
||||
valuesYaml: '',
|
||||
selectedTemplateId: '',
|
||||
selectedStorageId: '',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to deploy:', error);
|
||||
@ -184,10 +259,15 @@ export default function ChartsPage() {
|
||||
|
||||
const openDeployModal = (artifact: Artifact) => {
|
||||
setSelectedArtifact(artifact);
|
||||
setDeployForm(prev => ({
|
||||
...prev,
|
||||
setDeployForm({
|
||||
name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(),
|
||||
}));
|
||||
namespace: 'default',
|
||||
clusterId: clusters[0]?.id || '',
|
||||
description: '',
|
||||
valuesYaml: '',
|
||||
selectedTemplateId: '',
|
||||
selectedStorageId: '',
|
||||
});
|
||||
setShowDeployModal(true);
|
||||
};
|
||||
|
||||
@ -447,6 +527,46 @@ export default function ChartsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||
Values Template
|
||||
</label>
|
||||
<select
|
||||
value={deployForm.selectedTemplateId}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
|
||||
>
|
||||
<option value="">-- Select a template --</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name} (v{template.version}){template.is_default ? ' [Default]' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storages.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||
Storage Backend
|
||||
</label>
|
||||
<select
|
||||
value={deployForm.selectedStorageId}
|
||||
onChange={(e) => handleStorageChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
|
||||
>
|
||||
<option value="">-- Select storage (optional) --</option>
|
||||
{storages.map((storage) => (
|
||||
<option key={storage.id} value={storage.id}>
|
||||
{storage.name} ({storage.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||
Description
|
||||
|
||||
Reference in New Issue
Block a user