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

@ -27,8 +27,8 @@ export default function UsersManagementPage() {
adminApi.listUsers(),
workspaceApi.list(),
]);
setUsers(usersRes.data.users || []);
setWorkspaces(workspacesRes.data.workspaces || []);
setUsers(usersRes.data.data?.users || usersRes.data.users || []);
setWorkspaces(workspacesRes.data.data?.workspaces || workspacesRes.data.workspaces || []);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {

View File

@ -2,13 +2,14 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { workspaceApi } from '@/lib/api';
import type { WorkspaceDTO, QuotaDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types';
import { FolderKanban, Plus, Trash2, Edit, Settings } from 'lucide-react';
import { workspaceApi, clusterApi } from '@/lib/api';
import type { WorkspaceDTO, QuotaDTO, ClusterDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types';
import { FolderKanban, Plus, Trash2, Edit, Settings, Server } from 'lucide-react';
export default function WorkspacesPage() {
const { user } = useAuth();
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [quotas, setQuotas] = useState<Record<string, QuotaDTO[]>>({});
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
@ -18,6 +19,10 @@ export default function WorkspacesPage() {
const [formData, setFormData] = useState<CreateWorkspaceRequest>({
name: '',
description: '',
cluster_ids: [],
cpu: { hard_limit: 10, soft_limit: 8 },
gpu: { hard_limit: 2, soft_limit: 1 },
gpu_memory: { hard_limit: 16, soft_limit: 8 },
});
const [quotaFormData, setQuotaFormData] = useState<SetQuotasRequest>({
cpu: { hard_limit: 10, soft_limit: 8 },
@ -28,7 +33,7 @@ export default function WorkspacesPage() {
const fetchWorkspaces = async () => {
try {
const response = await workspaceApi.list();
setWorkspaces(response.data.workspaces || []);
setWorkspaces(response.data.data?.workspaces || response.data.workspaces || []);
} catch (error) {
console.error('Failed to fetch workspaces:', error);
} finally {
@ -36,6 +41,16 @@ export default function WorkspacesPage() {
}
};
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const clusterList = response.data.clusters || response.data || [];
setClusters(Array.isArray(clusterList) ? clusterList : []);
} catch (error) {
console.error('Failed to fetch clusters:', error);
}
};
const fetchQuotas = async (workspaceId: string) => {
try {
const response = await workspaceApi.getQuotas(workspaceId);
@ -47,6 +62,7 @@ export default function WorkspacesPage() {
useEffect(() => {
fetchWorkspaces();
fetchClusters();
}, []);
useEffect(() => {
@ -60,14 +76,23 @@ export default function WorkspacesPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const request = {
name: formData.name,
description: formData.description,
cluster_ids: formData.cluster_ids,
cpu: formData.cpu,
gpu: formData.gpu,
gpu_memory: formData.gpu_memory,
};
if (editingWorkspace) {
await workspaceApi.update(editingWorkspace.id, formData);
await workspaceApi.update(editingWorkspace.id, { name: formData.name, description: formData.description, cluster_ids: formData.cluster_ids });
} else {
await workspaceApi.create(formData);
await workspaceApi.create(request);
}
setShowForm(false);
setEditingWorkspace(null);
setFormData({ name: '', description: '' });
setFormData({ name: '', description: '', cluster_ids: [], cpu: { hard_limit: 10, soft_limit: 8 }, gpu: { hard_limit: 2, soft_limit: 1 }, gpu_memory: { hard_limit: 16, soft_limit: 8 } });
fetchWorkspaces();
} catch (error) {
console.error('Failed to save workspace:', error);
@ -77,7 +102,14 @@ export default function WorkspacesPage() {
const handleEdit = (workspace: WorkspaceDTO) => {
setEditingWorkspace(workspace);
setFormData({ name: workspace.name, description: workspace.description || '' });
setFormData({
name: workspace.name,
description: workspace.description || '',
cluster_ids: workspace.cluster_ids || [],
cpu: { hard_limit: 10, soft_limit: 8 },
gpu: { hard_limit: 2, soft_limit: 1 },
gpu_memory: { hard_limit: 16, soft_limit: 8 },
});
setShowForm(true);
};
@ -104,6 +136,16 @@ export default function WorkspacesPage() {
}
};
const handleClusterToggle = (clusterId: string) => {
setFormData(prev => {
const current = prev.cluster_ids || [];
const updated = current.includes(clusterId)
? current.filter(id => id !== clusterId)
: [...current, clusterId];
return { ...prev, cluster_ids: updated };
});
};
if (user?.role !== 'admin') {
return (
<div className="flex items-center justify-center h-64">
@ -126,13 +168,13 @@ export default function WorkspacesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Workspaces</h1>
<p className="text-[var(--muted-foreground)]">Manage workspaces and quotas</p>
<p className="text-[var(--muted-foreground)]">Manage workspaces, clusters and quotas</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingWorkspace(null);
setFormData({ name: '', description: '' });
setFormData({ name: '', description: '', cluster_ids: [], cpu: { hard_limit: 10, soft_limit: 8 }, gpu: { hard_limit: 2, soft_limit: 1 }, gpu_memory: { hard_limit: 16, soft_limit: 8 } });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
@ -143,8 +185,8 @@ export default function WorkspacesPage() {
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 overflow-y-auto py-8">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-lg border border-[var(--border)] m-auto">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingWorkspace ? 'Edit Workspace' : 'Add Workspace'}
</h2>
@ -165,9 +207,80 @@ export default function WorkspacesPage() {
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={3}
rows={2}
/>
</div>
{/* Cluster Selection */}
{!editingWorkspace && (
<>
<div>
<label className="label flex items-center gap-2">
<Server className="w-4 h-4" />
Assign Clusters
</label>
{clusters.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">No clusters available</p>
) : (
<div className="space-y-2 max-h-40 overflow-y-auto">
{clusters.map((cluster) => (
<label key={cluster.id} className="flex items-center gap-3 p-2 rounded-lg bg-[var(--secondary)] cursor-pointer hover:bg-[var(--secondary)]/80">
<input
type="checkbox"
checked={formData.cluster_ids?.includes(cluster.id) || false}
onChange={() => handleClusterToggle(cluster.id)}
className="w-4 h-4 rounded border-[var(--border)]"
/>
<div className="flex-1">
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
<p className="text-xs text-[var(--muted-foreground)]">{cluster.host}</p>
</div>
</label>
))}
</div>
)}
</div>
{/* Resource Quotas */}
<div className="border-t border-[var(--border)] pt-4">
<label className="label mb-3">Resource Quotas (Optional)</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[var(--muted-foreground)]">CPU (cores)</label>
<input
type="number"
value={formData.cpu?.hard_limit ?? 10}
onChange={(e) => setFormData({ ...formData, cpu: { hard_limit: parseFloat(e.target.value) || 0, soft_limit: formData.cpu?.soft_limit ?? 8 } })}
className="input"
min="0"
placeholder="Hard limit"
/>
</div>
<div>
<label className="text-xs text-[var(--muted-foreground)]">GPU (cards)</label>
<input
type="number"
value={formData.gpu?.hard_limit ?? 2}
onChange={(e) => setFormData({ ...formData, gpu: { hard_limit: parseFloat(e.target.value) || 0, soft_limit: formData.gpu?.soft_limit ?? 1 } })}
className="input"
min="0"
/>
</div>
<div>
<label className="text-xs text-[var(--muted-foreground)]">GPU Memory (GB)</label>
<input
type="number"
value={formData.gpu_memory?.hard_limit ?? 16}
onChange={(e) => setFormData({ ...formData, gpu_memory: { hard_limit: parseFloat(e.target.value) || 0, soft_limit: formData.gpu_memory?.soft_limit ?? 8 } })}
className="input"
min="0"
/>
</div>
</div>
</div>
</>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
@ -376,6 +489,23 @@ export default function WorkspacesPage() {
</div>
</div>
{/* Assigned Clusters */}
{workspace.cluster_ids && workspace.cluster_ids.length > 0 && (
<div className="mt-4 pt-4 border-t border-[var(--border)]">
<p className="text-sm font-medium text-[var(--foreground)] mb-2">Assigned Clusters</p>
<div className="flex flex-wrap gap-2">
{workspace.cluster_ids.map(clusterId => {
const cluster = clusters.find(c => c.id === clusterId);
return (
<span key={clusterId} className="px-2 py-1 bg-[var(--secondary)] rounded text-xs text-[var(--foreground)]">
{cluster?.name || clusterId.substring(0, 8)}
</span>
);
})}
</div>
</div>
)}
{/* Quotas Display */}
{quotas[workspace.id] && quotas[workspace.id].length > 0 && (
<div className="mt-4 pt-4 border-t border-[var(--border)]">

View File

@ -23,7 +23,12 @@ export default function ChartReferencesPage() {
const fetchChartRefs = async () => {
try {
const response = await chartReferenceApi.list();
setChartRefs(response.data || []);
const data = response.data;
if (Array.isArray(data)) {
setChartRefs(data);
} else {
setChartRefs(data?.data || data?.chart_references || data?.chartReferences || []);
}
} catch (error) {
console.error('Failed to fetch chart references:', error);
} finally {
@ -34,7 +39,12 @@ export default function ChartReferencesPage() {
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
setRegistries(response.data.registries || []);
const data = response.data;
if (Array.isArray(data)) {
setRegistries(data);
} else {
setRegistries(data?.registries || data?.data?.registries || []);
}
} catch (error) {
console.error('Failed to fetch registries:', error);
}

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" />}

View File

@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import { instanceApi, clusterApi } from '@/lib/api';
import { Rocket, RefreshCw, Trash2, ChevronRight, Loader2, AlertCircle, CheckCircle, Clock, XCircle } from 'lucide-react';
import Link from 'next/link';
interface InstanceDTO {
id: string;
clusterId: string;
clusterName?: string;
registryId?: string;
name: string;
namespace: string;
repository: string;
chart: string;
version: string;
description?: string;
status: string;
statusReason?: string;
lastError?: string;
revision: number;
createdAt: string;
updatedAt: string;
}
interface ClusterDTO {
id: string;
name: string;
host: string;
}
const statusConfig: Record<string, { icon: React.ReactNode; color: string; bg: string }> = {
pending: { icon: <Clock className="w-4 h-4" />, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
deployed: { icon: <CheckCircle className="w-4 h-4" />, color: 'text-green-500', bg: 'bg-green-500/10' },
failed: { icon: <XCircle className="w-4 h-4" />, color: 'text-red-500', bg: 'bg-red-500/10' },
deploying: { icon: <Loader2 className="w-4 h-4 animate-spin" />, color: 'text-blue-500', bg: 'bg-blue-500/10' },
};
export default function InstancesPage() {
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [instances, setInstances] = useState<InstanceDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingInstances, setIsLoadingInstances] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{ clusterId: string; instanceId: string } | null>(null);
useEffect(() => {
fetchClusters();
}, []);
useEffect(() => {
if (selectedClusterId) {
fetchInstances(selectedClusterId);
}
}, [selectedClusterId]);
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const data = response.data;
const clusterList: ClusterDTO[] = Array.isArray(data) ? data : (data?.clusters || []);
setClusters(clusterList);
if (clusterList.length > 0 && !selectedClusterId) {
setSelectedClusterId(clusterList[0].id);
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
} finally {
setIsLoading(false);
}
};
const fetchInstances = async (clusterId: string) => {
setIsLoadingInstances(true);
try {
const response = await instanceApi.list(clusterId);
const data = response.data;
const instanceList: InstanceDTO[] = Array.isArray(data) ? data : (data?.instances || []);
setInstances(instanceList);
} catch (error) {
console.error('Failed to fetch instances:', error);
setInstances([]);
} finally {
setIsLoadingInstances(false);
}
};
const handleDelete = async (clusterId: string, instanceId: string) => {
try {
await instanceApi.delete(clusterId, instanceId);
fetchInstances(clusterId);
setDeleteConfirm(null);
} catch (error) {
console.error('Failed to delete instance:', error);
alert('Failed to delete instance');
}
};
const getStatusStyle = (status: string) => {
const normalized = status?.toLowerCase() || 'pending';
return statusConfig[normalized] || statusConfig.pending;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Deployments</h1>
<p className="text-[var(--muted-foreground)]">View and manage your Helm chart deployments</p>
</div>
<button
onClick={() => selectedClusterId && fetchInstances(selectedClusterId)}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* Cluster Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-[var(--foreground)]">Cluster:</label>
<select
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
className="px-3 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
>
<option value="">Select a cluster</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
<Link
href="/charts"
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Rocket className="w-4 h-4" />
New Deployment
</Link>
</div>
{/* Instances List */}
{isLoadingInstances ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
) : instances.length === 0 ? (
<div className="card text-center py-12">
<Rocket className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<h3 className="text-lg font-medium text-[var(--foreground)] mb-2">No Deployments Yet</h3>
<p className="text-[var(--muted-foreground)] mb-4">
You haven&apos;t deployed any Helm charts to this cluster yet.
</p>
<Link
href="/charts"
className="inline-flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Rocket className="w-4 h-4" />
Deploy Your First Chart
</Link>
</div>
) : (
<div className="space-y-3">
{instances.map((instance) => {
const statusStyle = getStatusStyle(instance.status);
return (
<div key={instance.id} className="card p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-lg ${statusStyle.bg}`}>
<span className={statusStyle.color}>{statusStyle.icon}</span>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{instance.name}</h3>
<span className="badge">{instance.status}</span>
</div>
<div className="flex items-center gap-3 text-sm text-[var(--muted-foreground)] mt-1">
<span>{instance.chart}:{instance.version}</span>
<span>Namespace: {instance.namespace}</span>
<span>Revision: {instance.revision}</span>
</div>
{instance.lastError && (
<div className="flex items-center gap-2 text-sm text-red-500 mt-1">
<AlertCircle className="w-3 h-3" />
{instance.lastError}
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-sm text-[var(--muted-foreground)]">
Updated {new Date(instance.updatedAt).toLocaleString()}
</div>
<button
onClick={() => setDeleteConfirm({ clusterId: instance.clusterId, instanceId: instance.id })}
className="p-2 hover:bg-red-500/10 rounded-lg text-red-500 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
<ChevronRight className="w-5 h-5 text-[var(--muted-foreground)]" />
</div>
</div>
</div>
);
})}
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<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-md border border-[var(--border)]">
<h3 className="text-xl font-bold text-[var(--foreground)] mb-4">Delete Deployment</h3>
<p className="text-[var(--muted-foreground)] mb-6">
Are you sure you want to delete this deployment? This action cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="flex-1 px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirm.clusterId, deleteConfirm.instanceId)}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -3,14 +3,13 @@
import { useState, FormEvent, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { Shield, Loader2, CheckCircle } from 'lucide-react';
import { Shield, Loader2 } from 'lucide-react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loginSuccess, setLoginSuccess] = useState(false);
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
@ -37,20 +36,16 @@ export default function LoginPage() {
try {
await login({ username, password });
setLoginSuccess(true);
// Small delay to show success state, then redirect
setTimeout(() => {
router.push('/');
}, 500);
// Redirect immediately after successful login
window.location.href = '/';
} catch (err: unknown) {
setIsLoading(false);
if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as { response?: { data?: { message?: string } } };
setError(axiosErr.response?.data?.message || 'Login failed');
} else {
setError('Login failed. Please check your credentials.');
}
} finally {
setIsLoading(false);
}
};
@ -66,13 +61,6 @@ export default function LoginPage() {
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{loginSuccess && (
<div className="p-3 rounded-md bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.3)] flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<p className="text-sm text-green-500">Login successful! Redirecting...</p>
</div>
)}
{error && (
<div className="p-3 rounded-md bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.3)]">
<p className="text-sm text-[#ef4444]">{error}</p>

View File

@ -2,24 +2,36 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/lib/auth-context';
import { monitoringApi, clusterApi } from '@/lib/api';
import { Activity, Server, Container } from 'lucide-react';
import { Activity, Server, Container, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
interface DashboardStats {
totalClusters: number;
healthyClusters: number;
warningClusters: number;
errorClusters: number;
totalInstances: number;
runningInstances: number;
totalNodes: number;
}
interface ClusterStatus {
id: string;
name: string;
host: string;
status: string;
message?: string;
}
export default function DashboardPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [clusters, setClusters] = useState<Array<{ id: string; name: string; host: string }>>([]);
const [clusters, setClusters] = useState<ClusterStatus[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
// Redirect to login if not authenticated
useEffect(() => {
@ -28,8 +40,31 @@ export default function DashboardPage() {
}
}, [isLoading, isAuthenticated, router]);
const fetchClusterHealth = async (clusterId: string, clusterName: string, host: string): Promise<ClusterStatus> => {
try {
const res = await clusterApi.getHealth(clusterId);
const healthy = res.data?.healthy ?? false;
return {
id: clusterId,
name: clusterName,
host,
status: healthy ? 'healthy' : 'error',
message: res.data?.message,
};
} catch (error) {
return {
id: clusterId,
name: clusterName,
host,
status: 'error',
message: 'Failed to connect',
};
}
};
useEffect(() => {
const fetchData = async () => {
setFetchError(null);
try {
const [summaryRes, clustersRes] = await Promise.all([
monitoringApi.getSummary().catch(() => null),
@ -40,21 +75,35 @@ export default function DashboardPage() {
setStats({
totalClusters: summaryRes.data.totalClusters ?? summaryRes.data.total_clusters ?? 0,
healthyClusters: summaryRes.data.healthyClusters ?? summaryRes.data.healthy_clusters ?? 0,
warningClusters: summaryRes.data.warningClusters ?? summaryRes.data.warning_clusters ?? 0,
errorClusters: summaryRes.data.errorClusters ?? summaryRes.data.error_clusters ?? 0,
totalInstances: summaryRes.data.totalInstances ?? summaryRes.data.total_instances ?? 0,
runningInstances: summaryRes.data.runningInstances ?? summaryRes.data.running_instances ?? 0,
totalNodes: summaryRes.data.totalNodes ?? summaryRes.data.total_nodes ?? 0,
});
}
// Handle both {clusters: []} and array response
// Handle cluster list and fetch health for each
const clustersData = clustersRes?.data;
let clusterList: Array<{ id: string; name: string; host: string }> = [];
if (Array.isArray(clustersData)) {
setClusters(clustersData);
clusterList = clustersData;
} else if (clustersData?.clusters) {
setClusters(clustersData.clusters);
clusterList = clustersData.clusters;
}
if (clusterList.length > 0) {
// Fetch health for each cluster concurrently
const healthResults = await Promise.all(
clusterList.map(c => fetchClusterHealth(c.id, c.name, c.host))
);
setClusters(healthResults);
} else {
setClusters([]);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
setFetchError('Failed to load dashboard data. Please check your connection.');
} finally {
setIsLoadingData(false);
}
@ -65,6 +114,24 @@ export default function DashboardPage() {
}
}, [isLoading]);
const getStatusIcon = (status: ClusterStatus['status']) => {
switch (status) {
case 'healthy': return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'warning': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
case 'error': return <XCircle className="w-4 h-4 text-red-500" />;
default: return <Server className="w-4 h-4 text-gray-500" />;
}
};
const getStatusBadge = (status: ClusterStatus['status']) => {
switch (status) {
case 'healthy': return <span className="badge badge-success">Healthy</span>;
case 'warning': return <span className="badge bg-yellow-500/10 text-yellow-500">Warning</span>;
case 'error': return <span className="badge bg-red-500/10 text-red-500">Error</span>;
default: return <span className="badge bg-gray-500/10 text-gray-500">Unknown</span>;
}
};
if (isLoading || isLoadingData) {
return (
<div className="flex items-center justify-center h-64">
@ -83,8 +150,18 @@ export default function DashboardPage() {
</p>
</div>
{/* Error Banner */}
{fetchError && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
<span>{fetchError}</span>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
title="Total Clusters"
value={stats?.totalClusters ?? 0}
@ -92,30 +169,47 @@ export default function DashboardPage() {
color="blue"
/>
<StatCard
title="Healthy Clusters"
title="Healthy"
value={stats?.healthyClusters ?? 0}
icon={Activity}
icon={CheckCircle}
color="green"
/>
<StatCard
title="Total Instances"
value={stats?.totalInstances ?? 0}
title="Warning"
value={stats?.warningClusters ?? 0}
icon={AlertTriangle}
color="yellow"
/>
<StatCard
title="Error"
value={stats?.errorClusters ?? 0}
icon={XCircle}
color="red"
/>
<StatCard
title="Nodes"
value={stats?.totalNodes ?? 0}
icon={Container}
color="purple"
/>
<StatCard
title="Running Instances"
value={stats?.runningInstances ?? 0}
icon={Activity}
color="green"
/>
</div>
{/* Clusters List */}
<div className="card">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">Clusters</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--foreground)]">Clusters</h2>
<Link href="/clusters" className="text-sm text-[var(--primary)] hover:underline">
Manage Clusters
</Link>
</div>
{clusters.length === 0 ? (
<p className="text-[var(--muted-foreground)]">No clusters configured</p>
<div className="text-center py-8">
<Server className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)] mb-4">No clusters configured</p>
<Link href="/clusters" className="text-[var(--primary)] hover:underline">
Add your first cluster
</Link>
</div>
) : (
<div className="space-y-2">
{clusters.map((cluster) => (
@ -124,13 +218,16 @@ export default function DashboardPage() {
className="flex items-center justify-between p-3 rounded-lg bg-[var(--secondary)]"
>
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[var(--primary)]" />
{getStatusIcon(cluster.status)}
<div>
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</p>
{cluster.message && cluster.status !== 'healthy' && (
<p className="text-xs text-red-500 mt-1">{cluster.message}</p>
)}
</div>
</div>
<div className="badge badge-success">Active</div>
{getStatusBadge(cluster.status)}
</div>
))}
</div>
@ -149,13 +246,15 @@ function StatCard({
title: string;
value: number;
icon: React.ElementType;
color: 'blue' | 'green' | 'purple' | 'orange';
color: 'blue' | 'green' | 'purple' | 'orange' | 'yellow' | 'red';
}) {
const colorClasses = {
blue: 'text-blue-500 bg-blue-500/10',
green: 'text-green-500 bg-green-500/10',
purple: 'text-purple-500 bg-purple-500/10',
orange: 'text-orange-500 bg-orange-500/10',
yellow: 'text-yellow-500 bg-yellow-500/10',
red: 'text-red-500 bg-red-500/10',
};
return (

View File

@ -1,28 +1,40 @@
'use client';
import { useEffect, useState } from 'react';
import { storageApi } from '@/lib/api';
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest } from '@/lib/types';
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2 } from 'lucide-react';
import { storageApi, clusterApi } from '@/lib/api';
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest, ClusterDTO, StorageResolutionDTO } from '@/lib/types';
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-react';
export default function StoragePage() {
const [storages, setStorages] = useState<StorageDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingStorage, setEditingStorage] = useState<StorageDTO | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<'list' | 'layered'>('list');
const [showLayeredConfig, setShowLayeredConfig] = useState(false);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [resolvedStorage, setResolvedStorage] = useState<StorageResolutionDTO | null>(null);
const [isResolving, setIsResolving] = useState(false);
const [formData, setFormData] = useState<CreateStorageRequest>({
name: '',
type: 'nfs',
description: '',
is_default: false,
is_shared: false,
cluster_id: '',
});
const fetchStorages = async () => {
try {
const response = await storageApi.list();
setStorages(response.data || []);
const data = response.data;
if (Array.isArray(data)) {
setStorages(data);
} else {
setStorages(data?.storages || data?.data?.storages || []);
}
} catch (error) {
console.error('Failed to fetch storages:', error);
} finally {
@ -30,10 +42,50 @@ export default function StoragePage() {
}
};
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const data = response.data;
if (Array.isArray(data)) {
setClusters(data);
} else if (data && data.clusters) {
setClusters(data.clusters);
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
}
};
const resolveStorage = async (clusterId: string) => {
if (!clusterId) {
setResolvedStorage(null);
return;
}
setIsResolving(true);
try {
const response = await storageApi.resolve(clusterId);
setResolvedStorage(response.data);
} catch (error) {
console.error('Failed to resolve storage:', error);
setResolvedStorage(null);
} finally {
setIsResolving(false);
}
};
useEffect(() => {
fetchStorages();
fetchClusters();
}, []);
useEffect(() => {
if (selectedClusterId) {
resolveStorage(selectedClusterId);
} else {
setResolvedStorage(null);
}
}, [selectedClusterId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
@ -45,7 +97,7 @@ export default function StoragePage() {
}
setShowForm(false);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false, cluster_id: '' });
fetchStorages();
alert(editingStorage ? 'Storage backend updated successfully!' : 'Storage backend created successfully!');
} catch (error) {
@ -64,6 +116,7 @@ export default function StoragePage() {
description: storage.description || '',
is_default: storage.is_default,
is_shared: storage.is_shared,
cluster_id: (storage as any).workspace_id ? '' : (storage as any).cluster_id || '',
nfs: storage.config.nfs,
pv: storage.config.pv,
hostPath: storage.config.hostPath,
@ -157,7 +210,7 @@ export default function StoragePage() {
onClick={() => {
setShowForm(true);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false, cluster_id: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
@ -166,6 +219,152 @@ export default function StoragePage() {
</button>
</div>
{/* Tab Switcher */}
<div className="flex gap-2 border-b border-[var(--border)]">
<button
onClick={() => setActiveTab('list')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === 'list'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
<HardDrive className="w-4 h-4 inline mr-2" />
Storage Backends
</button>
<button
onClick={() => setActiveTab('layered')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === 'layered'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
<Layers className="w-4 h-4 inline mr-2" />
Layered Config
</button>
</div>
{/* Layered Config Tab */}
{activeTab === 'layered' && (
<div className="card">
<div className="mb-4">
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-2">Storage Resolution Preview</h3>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
Priority order: <span className="font-medium text-[var(--foreground)]">Workspace Default</span> &gt; <span className="font-medium text-[var(--foreground)]">Cluster Default</span> &gt; <span className="font-medium text-[var(--foreground)]">Shared Default</span>
</p>
<div className="flex items-center gap-4">
<select
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
className="input max-w-sm"
>
<option value="">Select a cluster...</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
{selectedClusterId && (
<button
onClick={() => resolveStorage(selectedClusterId)}
className="px-3 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-medium hover:opacity-90 flex items-center gap-1"
>
{isResolving && <Loader2 className="w-3 h-3 animate-spin" />}
Refresh
</button>
)}
</div>
</div>
{/* Priority Legend */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--secondary)]/50">
<div className="flex items-center gap-2 mb-1">
<span className="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center">1</span>
<span className="text-sm font-medium text-[var(--foreground)]">Workspace Default</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">Highest priority. Per-workspace storage config.</p>
</div>
<div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--secondary)]/50">
<div className="flex items-center gap-2 mb-1">
<span className="w-6 h-6 rounded-full bg-purple-500 text-white text-xs font-bold flex items-center justify-center">2</span>
<span className="text-sm font-medium text-[var(--foreground)]">Cluster Default</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">Medium priority. Cluster-specific storage.</p>
</div>
<div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--secondary)]/50">
<div className="flex items-center gap-2 mb-1">
<span className="w-6 h-6 rounded-full bg-green-500 text-white text-xs font-bold flex items-center justify-center">3</span>
<span className="text-sm font-medium text-[var(--foreground)]">Shared Default</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">Lowest priority. Shared across workspaces.</p>
</div>
</div>
{/* Resolution Result */}
{isResolving ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-[var(--primary)]" />
<span className="ml-2 text-[var(--muted-foreground)]">Resolving storage config...</span>
</div>
) : resolvedStorage?.storage ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<div className={`w-3 h-3 rounded-full ${
resolvedStorage.source === 'workspace' ? 'bg-blue-500' :
resolvedStorage.source === 'cluster' ? 'bg-purple-500' : 'bg-green-500'
}`} />
<div>
<p className="text-sm font-medium text-[var(--foreground)]">
Resolved from: <span className="uppercase">{resolvedStorage.source}</span>
</p>
<p className="text-sm text-[var(--muted-foreground)]">{resolvedStorage.storage.name}</p>
</div>
</div>
<div className="p-4 rounded-lg bg-[var(--secondary)] border border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
{getTypeIcon(resolvedStorage.storage.type)}
<span className="font-medium text-[var(--foreground)]">{resolvedStorage.storage.name}</span>
<span className="badge">{getTypeLabel(resolvedStorage.storage.type)}</span>
{resolvedStorage.storage.is_default && <span className="badge badge-info">Default</span>}
</div>
{renderConfig(resolvedStorage.storage)}
</div>
{resolvedStorage.values_yaml && (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-[var(--foreground)]">Generated Values YAML</span>
<button
onClick={() => {
navigator.clipboard.writeText(resolvedStorage.values_yaml!);
alert('Copied to clipboard!');
}}
className="text-xs text-[var(--primary)] hover:underline"
>
Copy
</button>
</div>
<pre className="p-3 rounded-lg bg-[var(--secondary)] border border-[var(--border)] text-xs font-mono text-[var(--foreground)] overflow-x-auto whitespace-pre-wrap">
{resolvedStorage.values_yaml}
</pre>
</div>
)}
</div>
) : (
<div className="text-center py-8">
<Layers className="w-10 h-10 mx-auto text-[var(--muted-foreground)] mb-3" />
<p className="text-[var(--muted-foreground)]">
{selectedClusterId
? 'No default storage configured for this workspace/cluster'
: 'Select a cluster to preview the resolved storage configuration'}
</p>
</div>
)}
</div>
)}
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
@ -263,6 +462,22 @@ export default function StoragePage() {
</div>
)}
<div>
<label className="label">Cluster (Optional)</label>
<select
value={formData.cluster_id || ''}
onChange={(e) => setFormData({ ...formData, cluster_id: e.target.value })}
className="input"
>
<option value="">No cluster (workspace/shared level)</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
</div>
<div>
<label className="label">Description</label>
<textarea
@ -353,6 +568,9 @@ export default function StoragePage() {
{storage.is_shared && (
<span className="badge badge-success">Shared</span>
)}
{(storage as any).cluster_id && (
<span className="badge">Cluster</span>
)}
</div>
{renderConfig(storage)}
{storage.description && (

View File

@ -27,7 +27,12 @@ export default function TemplatesPage() {
const fetchTemplates = async () => {
try {
const response = await valuesTemplateApi.list();
setTemplates(response.data || []);
const data = response.data;
if (Array.isArray(data)) {
setTemplates(data);
} else {
setTemplates(data?.templates || data?.data?.templates || []);
}
} catch (error) {
console.error('Failed to fetch templates:', error);
} finally {
@ -38,7 +43,12 @@ export default function TemplatesPage() {
const fetchChartRefs = async () => {
try {
const response = await chartReferenceApi.list();
setChartRefs(response.data || []);
const data = response.data;
if (Array.isArray(data)) {
setChartRefs(data);
} else {
setChartRefs(data?.chart_references || data?.data?.chart_references || data?.chartReferences || []);
}
} catch (error) {
console.error('Failed to fetch chart references:', error);
}

View File

@ -15,6 +15,7 @@ import {
HardDrive,
Package,
FileText,
Rocket,
} from 'lucide-react';
import { logout } from '@/lib/api';
@ -23,6 +24,7 @@ const navigation = [
{ name: 'Clusters', href: '/clusters', icon: Server },
{ name: 'Registries', href: '/registries', icon: Database },
{ name: 'Charts', href: '/charts', icon: Package },
{ name: 'Deployments', href: '/instances', icon: Rocket },
{ name: 'Storage', href: '/storage', icon: HardDrive },
{ name: 'Chart References', href: '/chart-references', icon: FileText },
{ name: 'Values Templates', href: '/templates', icon: FileText },

View File

@ -266,6 +266,9 @@ export const storageApi = {
delete: (storageId: string) =>
api.delete(`/storage-backends/${storageId}`),
resolve: (clusterId: string, workspaceId?: string) =>
api.get<any>('/storage-backends/resolve', { params: { cluster_id: clusterId, workspace_id: workspaceId } }),
};
// Chart Reference API

View File

@ -41,16 +41,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = async (credentials: LoginRequest) => {
const response = await authApi.login(credentials);
// API returns camelCase: accessToken, refreshToken
const access_token = response.data.accessToken;
const refresh_token = response.data.refreshToken;
// API returns wrapped: { message: "", data: { accessToken, refreshToken } }
const access_token = response.data.data?.accessToken || response.data.accessToken;
const refresh_token = response.data.data?.refreshToken || response.data.refreshToken;
if (!access_token) {
throw new Error('Login failed: no access token received');
}
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
// Fetch user info - API returns { message: "", data: { user: {...} } }
const userResponse = await authApi.getCurrentUser();
const user = userResponse.data.data.user;
const user = userResponse.data.data?.user || userResponse.data.user;
setUser(user);
localStorage.setItem('user', JSON.stringify(user));
};

View File

@ -51,6 +51,8 @@ export interface UserListResponse {
export interface WorkspaceDTO {
id: string;
name: string;
cluster_ids?: string[];
quotas?: QuotaDTO[];
description?: string;
created_by: string;
created_at: string;
@ -79,11 +81,16 @@ export interface WorkspaceListResponse {
export interface CreateWorkspaceRequest {
name: string;
description?: string;
cluster_ids?: string[];
cpu?: { hard_limit: number; soft_limit: number };
gpu?: { hard_limit: number; soft_limit: number };
gpu_memory?: { hard_limit: number; soft_limit: number };
}
export interface UpdateWorkspaceRequest {
name?: string;
description?: string;
cluster_ids?: string[];
}
export interface SetQuotasRequest {
@ -214,6 +221,7 @@ export interface InstanceDTO {
export interface CreateInstanceRequest {
name: string;
namespace: string;
registryId: string;
repository: string;
chart: string;
version: string;
@ -323,6 +331,7 @@ export interface CreateStorageRequest {
description?: string;
is_default?: boolean;
is_shared?: boolean;
cluster_id?: string;
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
@ -334,11 +343,19 @@ export interface UpdateStorageRequest {
description?: string;
is_default?: boolean;
is_shared?: boolean;
cluster_id?: string;
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
}
export interface StorageResolutionDTO {
storage?: StorageDTO;
values_yaml?: string;
source?: string; // workspace, cluster, shared
message?: string;
}
// Chart Reference Types
export interface ChartReferenceDTO {
id: string;