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:
Ivan087
2026-04-30 16:31:00 +08:00
parent 985369d40f
commit 47849042a7
42 changed files with 2029 additions and 255 deletions

View File

@ -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&#10;replicaCount: 2&#10;image:&#11; 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" />}