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:
@ -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 {
|
||||
|
||||
@ -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)]">
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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" />}
|
||||
|
||||
250
frontend/src/app/instances/page.tsx
Normal file
250
frontend/src/app/instances/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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> > <span className="font-medium text-[var(--foreground)]">Cluster Default</span> > <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 && (
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user