feat(frontend): add Helm chart browser, monitoring, chart-references and values templates pages

Add new frontend pages for the multi-tenant OCDP platform:

- Charts page (/charts): Browse Harbor OCI registries to list Helm chart repositories
  and versions, with deploy modal to launch charts on selected clusters
- Monitoring page (/monitoring): Display cluster metrics (CPU/Memory/GPU usage)
  and per-node details with resource utilization bars
- Chart References page (/chart-references): CRUD for chart metadata references
- Values Templates page (/templates): CRUD for Helm values templates with version
  history and rollback support
- Sidebar: Add Charts navigation, update Storage and Templates links
- api.ts: Add all API client functions (clusterApi, registryApi, instanceApi,
  monitoringApi, storageApi, chartReferenceApi, valuesTemplateApi,
  workspaceApi, userApi) with full TypeScript types

Note: deploy flow and values template rollback not yet end-to-end tested.
This commit is contained in:
Ivan087
2026-04-15 16:59:31 +08:00
parent c5e51ed069
commit 29d0310f03
283 changed files with 24658 additions and 36038 deletions

View File

@ -1,38 +0,0 @@
/**
* Main Application Component
* 主应用组件
*/
import { useLocation, useNavigate } from "react-router-dom";
import { useMemo } from "react";
import { AppRoutes } from "./routes/AppRoutes";
import { useAuth } from "./providers";
import { getNavItems } from "./constants/navigation";
/**
* Application root component
* Manages routing and global state
*/
export default function App() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated, login, logout } = useAuth();
// Generate navigation items based on current location
const navItems = useMemo(
() => getNavItems(location.pathname, navigate),
[location.pathname, navigate]
);
return (
<AppRoutes
isAuthenticated={isAuthenticated}
userName="User"
navItems={navItems}
onLogin={login}
onLogout={logout}
/>
);
}

View File

@ -0,0 +1,334 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { adminApi, workspaceApi } from '@/lib/api';
import type { UserDTO, WorkspaceDTO } from '@/lib/types';
import { Users, Plus, Trash2, Edit, Shield, ShieldOff } from 'lucide-react';
export default function UsersManagementPage() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<UserDTO[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<UserDTO | null>(null);
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
role: 'user',
workspace_id: '',
});
const fetchData = async () => {
try {
const [usersRes, workspacesRes] = await Promise.all([
adminApi.listUsers(),
workspaceApi.list(),
]);
setUsers(usersRes.data.users || []);
setWorkspaces(workspacesRes.data.workspaces || []);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingUser) {
await adminApi.updateUser(editingUser.id, {
email: formData.email || undefined,
});
} else {
await adminApi.createUser({
username: formData.username,
password: formData.password,
email: formData.email || undefined,
role: formData.role,
workspace_id: formData.workspace_id || undefined,
});
}
setShowForm(false);
setEditingUser(null);
setFormData({ username: '', password: '', email: '', role: 'user', workspace_id: '' });
fetchData();
} catch (error) {
console.error('Failed to save user:', error);
alert('Failed to save user');
}
};
const handleEdit = (user: UserDTO) => {
setEditingUser(user);
setFormData({
username: user.username,
password: '',
email: user.email || '',
role: user.role,
workspace_id: user.workspace_id || '',
});
setShowForm(true);
};
const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await adminApi.deleteUser(userId);
fetchData();
} catch (error) {
console.error('Failed to delete user:', error);
alert('Failed to delete user');
}
};
const handleToggleActive = async (user: UserDTO) => {
try {
await adminApi.setUserActive(user.id, !user.is_active);
fetchData();
} catch (error) {
console.error('Failed to toggle user status:', error);
alert('Failed to toggle user status');
}
};
const handleResetPassword = async (userId: string) => {
const newPassword = prompt('Enter new password:');
if (!newPassword || newPassword.length < 6) {
alert('Password must be at least 6 characters');
return;
}
try {
await adminApi.resetPassword(userId, newPassword);
alert('Password reset successfully');
} catch (error) {
console.error('Failed to reset password:', error);
alert('Failed to reset password');
}
};
if (currentUser?.role !== 'admin') {
return (
<div className="flex items-center justify-center h-64">
<p className="text-[var(--muted-foreground)]">Access denied. Admin only.</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">User Management</h1>
<p className="text-[var(--muted-foreground)]">Manage users and permissions</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingUser(null);
setFormData({ username: '', password: '', email: '', role: 'user', workspace_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"
>
<Plus className="w-4 h-4" />
Add User
</button>
</div>
{/* 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)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingUser ? 'Edit User' : 'Add User'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Username</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="input"
required
disabled={!!editingUser}
/>
</div>
{!editingUser && (
<div>
<label className="label">Password</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input"
required={!editingUser}
minLength={6}
/>
</div>
)}
<div>
<label className="label">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="input"
/>
</div>
<div>
<label className="label">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="input"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="label">Workspace</label>
<select
value={formData.workspace_id}
onChange={(e) => setFormData({ ...formData, workspace_id: e.target.value })}
className="input"
>
<option value="">No workspace (Admin only)</option>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.name}
</option>
))}
</select>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingUser(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
{editingUser ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Users Table */}
{users.length === 0 ? (
<div className="card text-center py-12">
<Users className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No users created</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Create your first user
</button>
</div>
) : (
<div className="card overflow-hidden p-0">
<table className="table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Workspace</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[var(--secondary)] flex items-center justify-center">
<span className="text-sm font-medium text-[var(--foreground)]">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<span className="font-medium text-[var(--foreground)]">{user.username}</span>
</div>
</td>
<td className="text-[var(--muted-foreground)]">{user.email || '-'}</td>
<td>
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-success'}`}>
{user.role}
</span>
</td>
<td className="text-[var(--muted-foreground)]">
{user.workspace_name || user.workspace_id || '-'}
</td>
<td>
<span className={`badge ${user.is_active ? 'badge-success' : 'badge-error'}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div className="flex items-center gap-1">
<button
onClick={() => handleToggleActive(user)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={user.is_active ? 'Deactivate' : 'Activate'}
>
{user.is_active ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
</button>
<button
onClick={() => handleResetPassword(user.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title="Reset Password"
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(user)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(user.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,401 @@
'use client';
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';
export default function WorkspacesPage() {
const { user } = useAuth();
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
const [quotas, setQuotas] = useState<Record<string, QuotaDTO[]>>({});
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [showQuotaForm, setShowQuotaForm] = useState(false);
const [editingWorkspace, setEditingWorkspace] = useState<WorkspaceDTO | null>(null);
const [selectedWorkspace, setSelectedWorkspace] = useState<WorkspaceDTO | null>(null);
const [formData, setFormData] = useState<CreateWorkspaceRequest>({
name: '',
description: '',
});
const [quotaFormData, setQuotaFormData] = useState<SetQuotasRequest>({
cpu: { hard_limit: 10, soft_limit: 8 },
gpu: { hard_limit: 2, soft_limit: 1 },
gpu_memory: { hard_limit: 16, soft_limit: 8 },
});
const fetchWorkspaces = async () => {
try {
const response = await workspaceApi.list();
setWorkspaces(response.data.workspaces || []);
} catch (error) {
console.error('Failed to fetch workspaces:', error);
} finally {
setIsLoading(false);
}
};
const fetchQuotas = async (workspaceId: string) => {
try {
const response = await workspaceApi.getQuotas(workspaceId);
setQuotas((prev) => ({ ...prev, [workspaceId]: response.data.quotas || [] }));
} catch (error) {
console.error('Failed to fetch quotas:', error);
}
};
useEffect(() => {
fetchWorkspaces();
}, []);
useEffect(() => {
workspaces.forEach((ws) => {
if (!quotas[ws.id]) {
fetchQuotas(ws.id);
}
});
}, [workspaces]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingWorkspace) {
await workspaceApi.update(editingWorkspace.id, formData);
} else {
await workspaceApi.create(formData);
}
setShowForm(false);
setEditingWorkspace(null);
setFormData({ name: '', description: '' });
fetchWorkspaces();
} catch (error) {
console.error('Failed to save workspace:', error);
alert('Failed to save workspace');
}
};
const handleEdit = (workspace: WorkspaceDTO) => {
setEditingWorkspace(workspace);
setFormData({ name: workspace.name, description: workspace.description || '' });
setShowForm(true);
};
const handleDelete = async (workspaceId: string) => {
if (!confirm('Are you sure you want to delete this workspace?')) return;
try {
await workspaceApi.delete(workspaceId);
fetchWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
alert('Failed to delete workspace');
}
};
const handleSaveQuotas = async () => {
if (!selectedWorkspace) return;
try {
await workspaceApi.setQuotas(selectedWorkspace.id, quotaFormData);
setShowQuotaForm(false);
fetchQuotas(selectedWorkspace.id);
} catch (error) {
console.error('Failed to save quotas:', error);
alert('Failed to save quotas');
}
};
if (user?.role !== 'admin') {
return (
<div className="flex items-center justify-center h-64">
<p className="text-[var(--muted-foreground)]">Access denied. Admin only.</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">Workspaces</h1>
<p className="text-[var(--muted-foreground)]">Manage workspaces and quotas</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingWorkspace(null);
setFormData({ name: '', description: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Workspace
</button>
</div>
{/* 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)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingWorkspace ? 'Edit Workspace' : 'Add Workspace'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={3}
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingWorkspace(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
{editingWorkspace ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Quota Form Modal */}
{showQuotaForm && selectedWorkspace && (
<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)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
Set Quotas for {selectedWorkspace.name}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">CPU (cores)</label>
<input
type="number"
value={quotaFormData.cpu?.hard_limit ?? 10}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
cpu: { hard_limit: parseFloat(e.target.value), soft_limit: quotaFormData.cpu?.soft_limit ?? 8 },
})
}
className="input"
min="0"
/>
</div>
<div>
<label className="label">CPU Warning</label>
<input
type="number"
value={quotaFormData.cpu?.soft_limit ?? 8}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
cpu: { hard_limit: quotaFormData.cpu?.hard_limit ?? 10, soft_limit: parseFloat(e.target.value) },
})
}
className="input"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">GPU (cards)</label>
<input
type="number"
value={quotaFormData.gpu?.hard_limit ?? 2}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu: { hard_limit: parseFloat(e.target.value), soft_limit: quotaFormData.gpu?.soft_limit ?? 1 },
})
}
className="input"
min="0"
/>
</div>
<div>
<label className="label">GPU Warning</label>
<input
type="number"
value={quotaFormData.gpu?.soft_limit ?? 1}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu: { hard_limit: quotaFormData.gpu?.hard_limit ?? 2, soft_limit: parseFloat(e.target.value) },
})
}
className="input"
min="0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">GPU Memory (GB)</label>
<input
type="number"
value={quotaFormData.gpu_memory?.hard_limit ?? 16}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu_memory: { hard_limit: parseFloat(e.target.value), soft_limit: quotaFormData.gpu_memory?.soft_limit ?? 8 },
})
}
className="input"
min="0"
/>
</div>
<div>
<label className="label">GPU Mem Warning</label>
<input
type="number"
value={quotaFormData.gpu_memory?.soft_limit ?? 8}
onChange={(e) =>
setQuotaFormData({
...quotaFormData,
gpu_memory: { hard_limit: quotaFormData.gpu_memory?.hard_limit ?? 16, soft_limit: parseFloat(e.target.value) },
})
}
className="input"
min="0"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowQuotaForm(false);
setSelectedWorkspace(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="button"
onClick={handleSaveQuotas}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
Save Quotas
</button>
</div>
</div>
</div>
</div>
)}
{/* Workspaces List */}
{workspaces.length === 0 ? (
<div className="card text-center py-12">
<FolderKanban className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No workspaces created</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Create your first workspace
</button>
</div>
) : (
<div className="grid gap-4">
{workspaces.map((workspace) => (
<div key={workspace.id} className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
<FolderKanban className="w-6 h-6 text-[var(--primary)]" />
</div>
<div>
<h3 className="font-semibold text-[var(--foreground)]">{workspace.name}</h3>
{workspace.description && (
<p className="text-sm text-[var(--muted-foreground)]">{workspace.description}</p>
)}
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Created: {new Date(workspace.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setSelectedWorkspace(workspace);
setShowQuotaForm(true);
}}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title="Set Quotas"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(workspace)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(workspace.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Quotas Display */}
{quotas[workspace.id] && quotas[workspace.id].length > 0 && (
<div className="mt-4 pt-4 border-t border-[var(--border)]">
<p className="text-sm font-medium text-[var(--foreground)] mb-2">Quotas</p>
<div className="grid grid-cols-3 gap-4">
{quotas[workspace.id].map((quota) => (
<div key={quota.id} className="bg-[var(--secondary)] rounded-lg p-3">
<p className="text-xs text-[var(--muted-foreground)] uppercase">{quota.resource_type}</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{quota.used} / {quota.hard_limit === 0 ? '∞' : quota.hard_limit}
</p>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,335 @@
'use client';
import { useEffect, useState } from 'react';
import { chartReferenceApi, registryApi } from '@/lib/api';
import type { ChartReferenceDTO, CreateChartReferenceRequest, UpdateChartReferenceRequest, RegistryDTO } from '@/lib/types';
import { Package, Plus, Trash2, Edit, ToggleLeft, ToggleRight, Search, Database } from 'lucide-react';
export default function ChartReferencesPage() {
const [chartRefs, setChartRefs] = useState<ChartReferenceDTO[]>([]);
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingChartRef, setEditingChartRef] = useState<ChartReferenceDTO | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [formData, setFormData] = useState<CreateChartReferenceRequest>({
registry_id: '',
repository: '',
chart_name: '',
description: '',
is_enabled: true,
});
const fetchChartRefs = async () => {
try {
const response = await chartReferenceApi.list();
setChartRefs(response.data || []);
} catch (error) {
console.error('Failed to fetch chart references:', error);
} finally {
setIsLoading(false);
}
};
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
setRegistries(response.data.registries || []);
} catch (error) {
console.error('Failed to fetch registries:', error);
}
};
useEffect(() => {
fetchChartRefs();
fetchRegistries();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingChartRef) {
await chartReferenceApi.update(editingChartRef.id, formData as UpdateChartReferenceRequest);
} else {
await chartReferenceApi.create(formData);
}
setShowForm(false);
setEditingChartRef(null);
setFormData({ registry_id: '', repository: '', chart_name: '', description: '', is_enabled: true });
fetchChartRefs();
} catch (error) {
console.error('Failed to save chart reference:', error);
alert('Failed to save chart reference');
}
};
const handleEdit = (chartRef: ChartReferenceDTO) => {
setEditingChartRef(chartRef);
setFormData({
registry_id: chartRef.registry_id,
repository: chartRef.repository,
chart_name: chartRef.chart_name,
description: chartRef.description || '',
is_enabled: chartRef.is_enabled,
});
setShowForm(true);
};
const handleDelete = async (chartRefId: string) => {
if (!confirm('Are you sure you want to delete this chart reference?')) return;
try {
await chartReferenceApi.delete(chartRefId);
fetchChartRefs();
} catch (error) {
console.error('Failed to delete chart reference:', error);
alert('Failed to delete chart reference');
}
};
const handleToggleEnabled = async (chartRef: ChartReferenceDTO) => {
try {
await chartReferenceApi.update(chartRef.id, { is_enabled: !chartRef.is_enabled });
fetchChartRefs();
} catch (error) {
console.error('Failed to toggle chart reference:', error);
}
};
const getRegistryName = (registryId: string) => {
const registry = registries.find(r => r.id === registryId);
return registry?.name || registryId;
};
const filteredChartRefs = chartRefs.filter(cr =>
cr.chart_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
cr.repository.toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">Chart References</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Manage Helm chart references from OCI registries
</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingChartRef(null);
setFormData({ registry_id: '', repository: '', chart_name: '', description: '', is_enabled: true });
}}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" />
Add Chart Reference
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Search chart references..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]"
/>
</div>
{/* Form Modal */}
{showForm && (
<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)]">
<h2 className="text-xl font-bold text-[var(--foreground)] mb-4">
{editingChartRef ? 'Edit Chart Reference' : 'Add Chart Reference'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Registry
</label>
<select
value={formData.registry_id}
onChange={(e) => setFormData({ ...formData, registry_id: e.target.value })}
required
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
>
<option value="">Select a registry</option>
{registries.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Repository (OCI path)
</label>
<input
type="text"
value={formData.repository}
onChange={(e) => setFormData({ ...formData, repository: e.target.value })}
placeholder="e.g., library/nginx"
required
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Chart Name
</label>
<input
type="text"
value={formData.chart_name}
onChange={(e) => setFormData({ ...formData, chart_name: e.target.value })}
placeholder="e.g., nginx"
required
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description..."
rows={2}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_enabled"
checked={formData.is_enabled}
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="is_enabled" className="text-sm text-[var(--foreground)]">
Enabled
</label>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingChartRef(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
type="submit"
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{editingChartRef ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Table */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--secondary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Chart
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Repository
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Registry
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{filteredChartRefs.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--muted-foreground)]">
No chart references found
</td>
</tr>
) : (
filteredChartRefs.map((cr) => (
<tr key={cr.id} className="hover:bg-[var(--secondary)] transition-colors">
<td className="px-4 py-3">
<button
onClick={() => handleToggleEnabled(cr)}
className="flex items-center gap-1"
>
{cr.is_enabled ? (
<ToggleRight className="w-5 h-5 text-green-500" />
) : (
<ToggleLeft className="w-5 h-5 text-gray-400" />
)}
</button>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-[var(--primary)]" />
<span className="font-medium text-[var(--foreground)]">{cr.chart_name}</span>
</div>
</td>
<td className="px-4 py-3 text-[var(--foreground)]">{cr.repository}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-[var(--muted-foreground)]">
<Database className="w-3 h-3" />
{getRegistryName(cr.registry_id)}
</div>
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)] text-sm">
{cr.description || '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(cr)}
className="p-1 hover:bg-[var(--secondary)] rounded"
>
<Edit className="w-4 h-4 text-[var(--muted-foreground)]" />
</button>
<button
onClick={() => handleDelete(cr.id)}
className="p-1 hover:bg-[var(--secondary)] rounded"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,523 @@
'use client';
import { useEffect, useState } from 'react';
import { registryApi, instanceApi, clusterApi } from '@/lib/api';
import { Package, Database, ChevronRight, Search, Rocket, X, Loader2 } from 'lucide-react';
interface RegistryDTO {
id: string;
name: string;
url: string;
description?: string;
username?: string;
insecure: boolean;
isShared: boolean;
createdAt: string;
updatedAt: string;
}
interface CreateInstanceRequest {
name: string;
namespace: string;
repository: string;
chart: string;
version: string;
description?: string;
values_yaml?: string;
registry_id?: string;
}
interface ClusterDTO {
id: string;
name: string;
host: string;
description?: string;
}
export default function ChartsPage() {
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [selectedRegistry, setSelectedRegistry] = useState<RegistryDTO | null>(null);
const [repositories, setRepositories] = useState<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
const [isLoadingArtifacts, setIsLoadingArtifacts] = useState(false);
const [showDeployModal, setShowDeployModal] = useState(false);
const [selectedArtifact, setSelectedArtifact] = useState<Artifact | null>(null);
const [isDeploying, setIsDeploying] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [deployForm, setDeployForm] = useState({
name: '',
namespace: 'default',
clusterId: '',
description: '',
valuesYaml: '',
});
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
const data = response.data;
setRegistries(Array.isArray(data) ? data : (data?.registries || []));
} catch (error) {
console.error('Failed to fetch registries:', error);
} finally {
setIsLoading(false);
}
};
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const data = response.data;
const clusterList = Array.isArray(data) ? data : (data?.clusters || []);
setClusters(clusterList);
if (clusterList.length > 0 && !deployForm.clusterId) {
setDeployForm(prev => ({ ...prev, clusterId: clusterList[0].id }));
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
}
};
const fetchRepositories = async (registryId: string) => {
setIsLoadingRepos(true);
setRepositories([]);
setSelectedRepo(null);
try {
const response = await registryApi.listRepositories(registryId);
setRepositories(response.data?.repositories || []);
} catch (error) {
console.error('Failed to fetch repositories:', error);
} finally {
setIsLoadingRepos(false);
}
};
const fetchArtifacts = async (registryId: string, repositoryName: string) => {
setIsLoadingArtifacts(true);
setArtifacts([]);
try {
const response = await registryApi.listArtifacts(registryId, repositoryName);
// Filter to only show chart type artifacts
const allArtifacts = Array.isArray(response.data) ? response.data : [];
const chartArtifacts = allArtifacts.filter((a: Artifact) => a.type === 'chart');
setArtifacts(chartArtifacts);
} catch (error) {
console.error('Failed to fetch artifacts:', error);
} finally {
setIsLoadingArtifacts(false);
}
};
useEffect(() => {
fetchRegistries();
fetchClusters();
}, []);
useEffect(() => {
if (selectedRegistry) {
fetchRepositories(selectedRegistry.id);
}
}, [selectedRegistry]);
useEffect(() => {
if (selectedRegistry && selectedRepo) {
fetchArtifacts(selectedRegistry.id, selectedRepo);
}
}, [selectedRepo]);
const filteredRepos = repositories.filter(repo =>
repo.toLowerCase().includes(searchQuery.toLowerCase())
);
// Only show chart repositories (those starting with "charts/")
const chartRepos = filteredRepos.filter(repo => repo.startsWith('charts/') || repo.includes('/charts/'));
const handleDeploy = async () => {
if (!selectedArtifact || !deployForm.clusterId || !deployForm.name) {
alert('Please fill in all required fields');
return;
}
setIsDeploying(true);
setDeployError(null);
try {
const request: CreateInstanceRequest = {
name: deployForm.name,
namespace: deployForm.namespace || 'default',
repository: selectedRepo!,
chart: selectedRepo!.split('/').pop() || selectedRepo!,
version: selectedArtifact.tag,
registry_id: selectedRegistry?.id,
description: deployForm.description,
values_yaml: deployForm.valuesYaml || undefined,
};
await instanceApi.create(deployForm.clusterId, request);
alert('Deployment created successfully!');
setShowDeployModal(false);
setSelectedArtifact(null);
setDeployForm({
name: '',
namespace: 'default',
clusterId: clusters[0]?.id || '',
description: '',
valuesYaml: '',
});
} catch (error: unknown) {
console.error('Failed to deploy:', error);
let errorMessage = 'Failed to create deployment';
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string; message?: string } } };
errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.error || errorMessage;
}
setDeployError(errorMessage);
} finally {
setIsDeploying(false);
}
};
const openDeployModal = (artifact: Artifact) => {
setSelectedArtifact(artifact);
setDeployForm(prev => ({
...prev,
name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(),
}));
setShowDeployModal(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Helm Charts</h1>
<p className="text-[var(--muted-foreground)]">Browse and deploy Helm charts from OCI registries</p>
</div>
{/* Registry Selection */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-[var(--foreground)]">Registry:</label>
<div className="flex gap-2 flex-wrap">
{registries.map((registry) => (
<button
key={registry.id}
onClick={() => {
setSelectedRegistry(registry);
setSelectedRepo(null);
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-all ${
selectedRegistry?.id === registry.id
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
: 'border-[var(--border)] text-[var(--foreground)] hover:border-[var(--primary)]'
}`}
>
<Database className="w-4 h-4" />
{registry.name}
</button>
))}
</div>
</div>
{/* Search */}
{selectedRegistry && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Search charts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]"
/>
</div>
)}
{/* Breadcrumb */}
{selectedRegistry && (
<div className="flex items-center gap-2 text-sm">
<button
onClick={() => {
setSelectedRegistry(null);
setSelectedRepo(null);
}}
className="text-[var(--primary)] hover:underline"
>
Registries
</button>
<ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
<span className="text-[var(--foreground)]">{selectedRegistry.name}</span>
{selectedRepo && (
<>
<ChevronRight className="w-4 h-4 text-[var(--muted-foreground)]" />
<span className="text-[var(--foreground)]">{selectedRepo}</span>
</>
)}
</div>
)}
{/* Content */}
{!selectedRegistry ? (
<div className="card text-center py-12">
<Package className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">Select a registry to browse Helm charts</p>
</div>
) : isLoadingRepos ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
) : !selectedRepo ? (
/* Chart Repositories List */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{chartRepos.length === 0 ? (
<div className="col-span-full card text-center py-12">
<Package className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">
{searchQuery ? 'No charts found matching your search' : 'No Helm charts found in this registry'}
</p>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="mt-4 text-[var(--primary)] hover:underline"
>
Clear search
</button>
)}
</div>
) : (
chartRepos.map((repo) => (
<button
key={repo}
onClick={() => setSelectedRepo(repo)}
className="card text-left hover:border-[var(--primary)] transition-all p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="w-6 h-6 text-[var(--primary)]" />
<div>
<p className="font-semibold text-[var(--foreground)]">
{repo.split('/').pop()}
</p>
<p className="text-xs text-[var(--muted-foreground)]">{repo}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-[var(--muted-foreground)]" />
</div>
</button>
))
)}
</div>
) : isLoadingArtifacts ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
) : (
/* Artifact/Version List */
<div className="space-y-4">
<button
onClick={() => setSelectedRepo(null)}
className="flex items-center gap-2 text-sm text-[var(--primary)] hover:underline"
>
<ChevronRight className="w-4 h-4 rotate-180" />
Back to charts
</button>
<h2 className="text-lg font-semibold text-[var(--foreground)]">
Versions of {selectedRepo.split('/').pop()}
</h2>
{artifacts.length === 0 ? (
<div className="card text-center py-12">
<p className="text-[var(--muted-foreground)]">No versions found</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{artifacts.map((artifact, index) => (
<div key={index} className="card">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-mono text-lg font-bold text-[var(--foreground)]">
{artifact.tag}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{artifact.mediaType}
</p>
</div>
{index === 0 && (
<span className="badge badge-success">Latest</span>
)}
</div>
<p className="text-xs text-[var(--muted-foreground)] mb-4">
Size: {formatSize(artifact.size)}
</p>
<button
onClick={() => openDeployModal(artifact)}
className="w-full flex items-center justify-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
</button>
</div>
))}
</div>
)}
</div>
)}
{/* 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="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-[var(--foreground)]">Deploy Chart</h2>
<button
onClick={() => {
setShowDeployModal(false);
setDeployError(null);
}}
className="p-1 hover:bg-[var(--secondary)] rounded"
>
<X className="w-5 h-5 text-[var(--muted-foreground)]" />
</button>
</div>
<div className="mb-4 p-3 bg-[var(--secondary)] rounded-lg">
<p className="text-sm text-[var(--muted-foreground)]">Deploying</p>
<p className="font-semibold text-[var(--foreground)]">
{selectedRepo}:{selectedArtifact.tag}
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Release Name *
</label>
<input
type="text"
value={deployForm.name}
onChange={(e) => setDeployForm({ ...deployForm, name: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
placeholder="my-release"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Namespace
</label>
<input
type="text"
value={deployForm.namespace}
onChange={(e) => setDeployForm({ ...deployForm, namespace: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
placeholder="default"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Cluster *
</label>
<select
value={deployForm.clusterId}
onChange={(e) => setDeployForm({ ...deployForm, clusterId: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
required
>
<option value="">Select a cluster</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description
</label>
<textarea
value={deployForm.description}
onChange={(e) => setDeployForm({ ...deployForm, description: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
rows={2}
placeholder="Optional description..."
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Custom Values (values.yaml)
</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"
rows={4}
placeholder="# Optional: Override chart values&#10;replicaCount: 2&#10;image:&#11; tag: latest"
/>
</div>
{deployError && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
{deployError}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowDeployModal(false);
setDeployError(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
type="button"
onClick={handleDeploy}
disabled={isDeploying || !deployForm.name || !deployForm.clusterId}
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" />}
{isDeploying ? 'Deploying...' : 'Deploy'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
interface Artifact {
repositoryName: string;
tag: string;
type: string;
mediaType: string;
size: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}

View File

@ -0,0 +1,348 @@
'use client';
import { useEffect, useState } from 'react';
import { clusterApi } from '@/lib/api';
import type { ClusterDTO, CreateClusterRequest } from '@/lib/types';
import { Server, Plus, Trash2, Edit, AlertCircle, CheckCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
export default function ClustersPage() {
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingCluster, setEditingCluster] = useState<ClusterDTO | null>(null);
const [showKubeconfig, setShowKubeconfig] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateClusterRequest>({
name: '',
host: '',
description: '',
isolationMode: 'namespace',
isShared: false,
caData: '',
certData: '',
keyData: '',
token: '',
});
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
// API returns array directly or { clusters: [] }
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);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchClusters();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Filter out empty kubeconfig fields
const dataToSend = {
...formData,
caData: formData.caData || undefined,
certData: formData.certData || undefined,
keyData: formData.keyData || undefined,
token: formData.token || undefined,
};
if (editingCluster) {
await clusterApi.update(editingCluster.id, dataToSend);
} else {
await clusterApi.create(dataToSend);
}
setShowForm(false);
setEditingCluster(null);
setFormData({ name: '', host: '', description: '', isolationMode: 'namespace', isShared: false, caData: '', certData: '', keyData: '', token: '' });
fetchClusters();
alert(editingCluster ? 'Cluster updated successfully!' : 'Cluster created and connected successfully!');
} catch (error: unknown) {
console.error('Failed to save cluster:', error);
let errorMessage = 'Failed to save cluster';
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string; message?: string } } };
if (axiosError.response?.data?.message) {
errorMessage = axiosError.response.data.message;
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
}
alert('Error: ' + errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (cluster: ClusterDTO) => {
setEditingCluster(cluster);
setFormData({
name: cluster.name,
host: cluster.host,
description: cluster.description || '',
isolationMode: cluster.isolationMode || 'namespace',
isShared: cluster.isShared,
caData: cluster.hasCaData ? '********' : '',
certData: cluster.hasCertData ? '********' : '',
keyData: cluster.hasKeyData ? '********' : '',
token: cluster.hasToken ? '********' : '',
});
// Show kubeconfig section if any cert is configured
setShowKubeconfig(cluster.hasCaData || cluster.hasCertData || cluster.hasKeyData || cluster.hasToken);
setShowForm(true);
};
const handleDelete = async (clusterId: string) => {
if (!confirm('Are you sure you want to delete this cluster?')) return;
try {
await clusterApi.delete(clusterId);
fetchClusters();
} catch (error) {
console.error('Failed to delete cluster:', error);
alert('Failed to delete cluster');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">Clusters</h1>
<p className="text-[var(--muted-foreground)]">Manage Kubernetes clusters</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingCluster(null);
setShowKubeconfig(false);
setFormData({ name: '', host: '', description: '', isolationMode: 'namespace', isShared: false, caData: '', certData: '', keyData: '', token: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Cluster
</button>
</div>
{/* 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)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingCluster ? 'Edit Cluster' : 'Add Cluster'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Host</label>
<input
type="text"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
className="input"
placeholder="https://kubernetes.example.com:6443"
required
/>
</div>
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={3}
/>
</div>
<div>
<label className="label">Isolation Mode</label>
<select
value={formData.isolationMode}
onChange={(e) => setFormData({ ...formData, isolationMode: e.target.value })}
className="input"
>
<option value="namespace">Namespace</option>
<option value="cluster">Cluster</option>
</select>
</div>
{/* Kubeconfig Section */}
<div>
<button
type="button"
onClick={() => setShowKubeconfig(!showKubeconfig)}
className="flex items-center gap-2 text-sm text-[var(--primary)] hover:underline"
>
{showKubeconfig ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
Kubeconfig (Optional)
</button>
</div>
{showKubeconfig && (
<>
<div>
<label className="label">CA Data (base64)</label>
<textarea
value={formData.caData || ''}
onChange={(e) => setFormData({ ...formData, caData: e.target.value })}
className="input font-mono text-xs"
rows={2}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
/>
</div>
<div>
<label className="label">Client Certificate (base64)</label>
<textarea
value={formData.certData || ''}
onChange={(e) => setFormData({ ...formData, certData: e.target.value })}
className="input font-mono text-xs"
rows={2}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
/>
</div>
<div>
<label className="label">Client Key (base64)</label>
<textarea
value={formData.keyData || ''}
onChange={(e) => setFormData({ ...formData, keyData: e.target.value })}
className="input font-mono text-xs"
rows={2}
placeholder="LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..."
/>
</div>
<div>
<label className="label">Token</label>
<input
type="password"
value={formData.token || ''}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
className="input"
placeholder="Bearer token (optional)"
/>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isShared"
checked={formData.isShared || false}
onChange={(e) => setFormData({ ...formData, isShared: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isShared" className="text-sm text-[var(--foreground)]">
Shared Cluster (visible to all workspaces)
</label>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingCluster(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isSubmitting ? 'Saving...' : (editingCluster ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Clusters List */}
{clusters.length === 0 ? (
<div className="card text-center py-12">
<Server className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No clusters configured</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Add your first cluster
</button>
</div>
) : (
<div className="grid gap-4">
{clusters.map((cluster) => (
<div key={cluster.id} className="card flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
<Server className="w-6 h-6 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{cluster.name}</h3>
{cluster.isShared && (
<span className="badge badge-info">Shared</span>
)}
</div>
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</p>
{cluster.description && (
<p className="text-sm text-[var(--muted-foreground)] mt-1">{cluster.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{cluster.hasCaData ? (
<span title="Certificate configured"><CheckCircle className="w-4 h-4 text-green-500" /></span>
) : (
<span title="No certificate"><AlertCircle className="w-4 h-4 text-yellow-500" /></span>
)}
<button
onClick={() => handleEdit(cluster)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(cluster.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,130 +0,0 @@
/**
* Navigation Configuration
* 导航配置 - 集中管理导航菜单项
*/
import { Home, Settings, Server, Database, Package, LineChart } from "lucide-react";
/**
* Navigation item type
*/
export interface NavItem {
key: string;
label: string;
icon: React.ReactNode;
active?: boolean;
onClick?: () => void;
children?: NavItem[];
}
/**
* Page info type for header display
*/
export interface PageInfo {
title: string;
icon: React.ReactNode;
}
/**
* Get navigation items
* @param currentPath - Current route path
* @param navigate - Navigation function
* @returns Navigation items array
*/
export const getNavItems = (
currentPath: string,
navigate: (path: string) => void
): NavItem[] => [
{
key: "home",
label: "Home",
icon: <Home className="w-4 h-4 text-secondary" />,
active: currentPath === "/home",
onClick: () => navigate("/home"),
},
// Configuration
{
key: "configuration",
label: "Configuration",
icon: <Settings className="w-4 h-4 text-brand-accent" />,
children: [
{
key: "configuration-clusters",
label: "Clusters",
icon: <Server className="w-4 h-4 text-accent-teal" />,
active: currentPath === "/configuration/clusters",
onClick: () => navigate("/configuration/clusters"),
},
{
key: "configuration-registries",
label: "Registries",
icon: <Database className="w-4 h-4 text-brand-light" />,
active: currentPath === "/configuration/registries",
onClick: () => navigate("/configuration/registries"),
},
],
},
// Monitoring - 监控资源状态
{
key: "monitoring",
label: "Monitoring",
icon: <LineChart className="w-4 h-4 text-accent-teal" />,
children: [
{
key: "monitoring-clusters",
label: "Clusters",
icon: <Server className="w-4 h-4 text-accent-teal" />,
active: currentPath === "/monitoring/clusters",
onClick: () => navigate("/monitoring/clusters"),
},
],
},
// Artifact - 浏览和部署制品
{
key: "artifact",
label: "Artifact",
icon: <Package className="w-4 h-4 text-brand-light" />,
children: [
{
key: "artifact-registries",
label: "Registries",
icon: <Database className="w-4 h-4 text-brand-light" />,
active: currentPath === "/artifact/registries",
onClick: () => navigate("/artifact/registries"),
},
{
key: "artifact-instances",
label: "Instances",
icon: <Package className="w-4 h-4 text-brand-accent" />,
active: currentPath === "/artifact/instances",
onClick: () => navigate("/artifact/instances"),
},
],
},
];
/**
* Get page header info based on current path
* @param pathname - Current route pathname
* @returns Page info object
*/
export const getPageInfo = (pathname: string): PageInfo => {
if (pathname === "/artifact/registries") {
return { title: "Artifact Browser", icon: <Package className="w-6 h-6 text-brand-light" /> };
}
if (pathname === "/artifact/instances") {
return { title: "Artifact - Instances", icon: <Package className="w-6 h-6 text-brand-accent" /> };
}
if (pathname === "/configuration/clusters") {
return { title: "Configuration - Clusters", icon: <Server className="w-6 h-6 text-accent-teal" /> };
}
if (pathname === "/configuration/registries") {
return { title: "Configuration - Registries", icon: <Database className="w-6 h-6 text-brand-light" /> };
}
if (pathname === "/monitoring/clusters") {
return { title: "Monitoring - Clusters", icon: <LineChart className="w-6 h-6 text-accent-teal" /> };
}
return { title: "OCDP Platform", icon: <Home className="w-6 h-6 text-secondary" /> };
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,142 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #ededed;
--card: #171717;
--card-foreground: #ededed;
--popover: #171717;
--popover-foreground: #ededed;
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #262626;
--secondary-foreground: #ededed;
--muted: #262626;
--muted-foreground: #a1a1a1;
--accent: #262626;
--accent-foreground: #ededed;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #262626;
--input: #262626;
--ring: #3b82f6;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* Custom utility classes */
.card {
background-color: var(--card);
border-radius: 0.5rem;
border: 1px solid var(--border);
padding: 1.5rem;
}
.input {
background-color: var(--input);
border: 1px solid var(--border);
color: var(--foreground);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
width: 100%;
}
.input:focus {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
margin-bottom: 0.25rem;
}
/* Table styles */
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
color: var(--muted-foreground);
font-size: 0.75rem;
text-transform: uppercase;
}
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.table tr:hover {
background-color: var(--secondary);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success {
background-color: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.badge-warning {
background-color: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.badge-error {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.badge-info {
background-color: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}

View File

@ -1,11 +0,0 @@
/**
* App Module - Unified Export
* 应用模块统一导出
*/
export { default as App } from "./App";
export * from "./providers";
export * from "./routes/RouteGuard";
export * from "./constants/navigation";

View File

@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
import { AuthProvider } from "@/lib/auth-context";
import { ClientLayout } from "@/components/client-layout";
export const metadata: Metadata = {
title: "OCDP Platform",
description: "Open Cloud Development Platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="min-h-screen bg-[var(--background)]">
<AuthProvider>
<ClientLayout>{children}</ClientLayout>
</AuthProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,13 @@
'use client';
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
{children}
</div>
);
}

View File

@ -0,0 +1,124 @@
'use client';
import { useState, FormEvent, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { Shield, Loader2, CheckCircle } 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();
// Redirect if already logged in
useEffect(() => {
if (!authLoading && isAuthenticated) {
router.push('/');
}
}, [authLoading, isAuthenticated, router]);
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login({ username, password });
setLoginSuccess(true);
// Small delay to show success state, then redirect
setTimeout(() => {
router.push('/');
}, 500);
} catch (err: unknown) {
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);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
<div className="w-full max-w-md p-8">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--primary)] mb-4">
<Shield className="w-8 h-8 text-[var(--primary-foreground)]" />
</div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">OCDP Platform</h1>
<p className="text-[var(--muted-foreground)] mt-2">Sign in to your account</p>
</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>
</div>
)}
<div>
<label htmlFor="username" className="label">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input"
placeholder="Enter your username"
required
/>
</div>
<div>
<label htmlFor="password" className="label">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
placeholder="Enter your password"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,278 @@
'use client';
import { useEffect, useState } from 'react';
import { monitoringApi } from '@/lib/api';
import { Activity, Server, Cpu, HardDrive, CircleDot } from 'lucide-react';
interface ClusterMetrics {
clusterId: string;
clusterName: string;
status: string;
nodeCount: number;
podCount: number;
cpuUsage: number;
memoryUsage: number;
totalCpu?: string;
totalMemory?: string;
totalGpu?: number;
usedCpu?: string;
usedMemory?: string;
usedGpu?: number;
gpuUsage?: number;
uptime?: string;
nodes?: NodeMetric[];
}
interface NodeMetric {
nodeName: string;
status: string;
role: string;
age: string;
podCount: number;
cpuCapacity: string;
cpuAllocatable: string;
cpuUsage: string;
cpuPercent: number;
memoryCapacity: string;
memoryAllocatable: string;
memoryUsage: string;
memoryPercent: number;
gpuCapacity?: number;
gpuUsage?: number;
gpuPercent?: number;
gpuType?: string;
osImage?: string;
kernelVersion?: string;
containerRuntime?: string;
kubeletVersion?: string;
}
export default function MonitoringPage() {
const [clusterMonitoring, setClusterMonitoring] = useState<ClusterMetrics[]>([]);
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchMonitoring = async () => {
try {
const response = await monitoringApi.listClusterMonitoring();
const data = Array.isArray(response.data) ? response.data : [];
setClusterMonitoring(data);
// Auto-select first cluster if available
if (data.length > 0 && !selectedCluster) {
setSelectedCluster(data[0].clusterId);
}
} catch (error) {
console.error('Failed to fetch monitoring data:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchMonitoring();
}, []);
const selectedClusterData = clusterMonitoring.find(c => c.clusterId === selectedCluster);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Monitoring</h1>
<p className="text-[var(--muted-foreground)]">Monitor cluster and node health</p>
</div>
{/* Cluster Monitoring Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{clusterMonitoring.length === 0 ? (
<div className="col-span-full card text-center py-12">
<Activity className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No clusters configured or accessible</p>
</div>
) : (
clusterMonitoring.map((cluster) => (
<div
key={cluster.clusterId}
className={`card cursor-pointer transition-all hover:border-[var(--primary)] ${
selectedCluster === cluster.clusterId ? 'border-[var(--primary)] ring-1 ring-[var(--primary)]' : ''
}`}
onClick={() => setSelectedCluster(cluster.clusterId)}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[var(--primary)]" />
<h3 className="font-semibold text-[var(--foreground)]">{cluster.clusterName}</h3>
</div>
<span className={`badge ${
cluster.status === 'healthy' ? 'badge-success' :
cluster.status === 'warning' ? 'badge-warning' : 'badge-error'
}`}>
{cluster.status}
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-[var(--muted-foreground)]">CPU Usage</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.cpuUsage?.toFixed(1)}%
</p>
{cluster.totalCpu && (
<p className="text-xs text-[var(--muted-foreground)]">{cluster.usedCpu} / {cluster.totalCpu}</p>
)}
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">Memory Usage</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.memoryUsage?.toFixed(1)}%
</p>
{cluster.totalMemory && (
<p className="text-xs text-[var(--muted-foreground)]">{cluster.usedMemory} / {cluster.totalMemory}</p>
)}
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">Nodes</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{cluster.nodeCount}</p>
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">Pods</p>
<p className="text-lg font-semibold text-[var(--foreground)]">{cluster.podCount}</p>
</div>
{cluster.totalGpu !== undefined && cluster.totalGpu > 0 && (
<>
<div>
<p className="text-xs text-[var(--muted-foreground)]">GPU</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.gpuUsage?.toFixed(0) ?? 0}%
</p>
</div>
<div>
<p className="text-xs text-[var(--muted-foreground)]">GPU Capacity</p>
<p className="text-lg font-semibold text-[var(--foreground)]">
{cluster.usedGpu ?? 0} / {cluster.totalGpu}
</p>
</div>
</>
)}
</div>
{cluster.uptime && (
<p className="text-xs text-[var(--muted-foreground)] mt-3">Uptime: {cluster.uptime}</p>
)}
</div>
))
)}
</div>
{/* Node Metrics Table */}
{selectedClusterData && selectedClusterData.nodes && (
<div className="card">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
Nodes in {selectedClusterData.clusterName}
</h2>
{selectedClusterData.nodes.length === 0 ? (
<p className="text-[var(--muted-foreground)]">No node data available</p>
) : (
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>Node</th>
<th>Role</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
<th>GPU</th>
<th>Pods</th>
</tr>
</thead>
<tbody>
{selectedClusterData.nodes.map((node) => (
<tr key={node.nodeName}>
<td>
<div>
<p className="font-medium text-[var(--foreground)]">{node.nodeName}</p>
<p className="text-xs text-[var(--muted-foreground)]">{node.osImage}</p>
</div>
</td>
<td>
<span className={`badge ${node.role === 'control-plane' ? 'badge-info' : 'badge-secondary'}`}>
{node.role}
</span>
</td>
<td>
<span className={`flex items-center gap-1 ${
node.status === 'Ready' ? 'text-green-500' : 'text-red-500'
}`}>
<CircleDot className="w-3 h-3" />
{node.status}
</span>
</td>
<td>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--secondary)] rounded-full overflow-hidden">
<div
className="h-full bg-blue-500"
style={{ width: `${Math.min(node.cpuPercent, 100)}%` }}
/>
</div>
<span className="text-sm text-[var(--foreground)] whitespace-nowrap">
{node.cpuUsage} ({node.cpuPercent.toFixed(1)}%)
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">{node.cpuCapacity}</p>
</td>
<td>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--secondary)] rounded-full overflow-hidden">
<div
className="h-full bg-purple-500"
style={{ width: `${Math.min(node.memoryPercent, 100)}%` }}
/>
</div>
<span className="text-sm text-[var(--foreground)] whitespace-nowrap">
{node.memoryPercent.toFixed(1)}%
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">{node.memoryUsage}</p>
</td>
<td>
{node.gpuCapacity && node.gpuCapacity > 0 ? (
<div>
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-[var(--secondary)] rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{ width: `${node.gpuPercent ?? 0}%` }}
/>
</div>
<span className="text-sm text-[var(--foreground)]">
{node.gpuPercent ?? 0}%
</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{node.gpuCapacity}x {node.gpuType || 'GPU'}
</p>
</div>
) : (
<span className="text-[var(--muted-foreground)]">-</span>
)}
</td>
<td className="text-[var(--foreground)]">{node.podCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

174
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,174 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { monitoringApi, clusterApi } from '@/lib/api';
import { Activity, Server, Container } from 'lucide-react';
interface DashboardStats {
totalClusters: number;
healthyClusters: number;
totalInstances: number;
runningInstances: number;
totalNodes: number;
}
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 [isLoadingData, setIsLoadingData] = useState(true);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isLoading, isAuthenticated, router]);
useEffect(() => {
const fetchData = async () => {
try {
const [summaryRes, clustersRes] = await Promise.all([
monitoringApi.getSummary().catch(() => null),
clusterApi.list().catch(() => null),
]);
if (summaryRes?.data) {
setStats({
totalClusters: summaryRes.data.totalClusters ?? summaryRes.data.total_clusters ?? 0,
healthyClusters: summaryRes.data.healthyClusters ?? summaryRes.data.healthy_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
const clustersData = clustersRes?.data;
if (Array.isArray(clustersData)) {
setClusters(clustersData);
} else if (clustersData?.clusters) {
setClusters(clustersData.clusters);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setIsLoadingData(false);
}
};
if (!isLoading) {
fetchData();
}
}, [isLoading]);
if (isLoading || isLoadingData) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Dashboard</h1>
<p className="text-[var(--muted-foreground)]">
Welcome back, {user?.username}
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Total Clusters"
value={stats?.totalClusters ?? 0}
icon={Server}
color="blue"
/>
<StatCard
title="Healthy Clusters"
value={stats?.healthyClusters ?? 0}
icon={Activity}
color="green"
/>
<StatCard
title="Total Instances"
value={stats?.totalInstances ?? 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>
{clusters.length === 0 ? (
<p className="text-[var(--muted-foreground)]">No clusters configured</p>
) : (
<div className="space-y-2">
{clusters.map((cluster) => (
<div
key={cluster.id}
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)]" />
<div>
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</p>
</div>
</div>
<div className="badge badge-success">Active</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function StatCard({
title,
value,
icon: Icon,
color,
}: {
title: string;
value: number;
icon: React.ElementType;
color: 'blue' | 'green' | 'purple' | 'orange';
}) {
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',
};
return (
<div className="card">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-[var(--muted-foreground)]">{title}</p>
<p className="text-2xl font-bold text-[var(--foreground)]">{value}</p>
</div>
</div>
</div>
);
}

View File

@ -1,23 +0,0 @@
/**
* Auth Context
* Authentication context - separated for Fast Refresh compatibility
*/
import { createContext } from "react";
import type { AuthResponse } from "@/api";
export interface User {
username: string;
role?: string;
}
export interface AuthContextType {
token: string | null;
user: User | null;
isAuthenticated: boolean;
login: (response: AuthResponse) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);

View File

@ -1,103 +0,0 @@
/**
* Authentication Provider
* 认证提供者 - 管理用户认证状态和 token
*/
import { useState, useEffect } from "react";
import type { ReactNode } from "react";
import type { AuthResponse } from "@/api";
import { setAuthToken } from "@/api";
import { AuthContext, type User } from "./AuthContext";
interface AuthProviderProps {
children: ReactNode;
devMode?: boolean;
}
/**
* Auth Provider Component
* Manages authentication state and provides auth context
*/
export const AuthProvider = ({ children, devMode = false }: AuthProviderProps) => {
const [token, setToken] = useState<string | null>(devMode ? "dev-token" : null);
const [user, setUser] = useState<User | null>(null);
// Initialize: read token and user from localStorage
useEffect(() => {
if (devMode) {
const devUser: User = {
username: "dev-user",
role: "admin",
};
localStorage.setItem("access_token", "dev-token");
localStorage.setItem("user", JSON.stringify(devUser));
setToken("dev-token");
setUser(devUser);
return;
}
const storedToken = localStorage.getItem("access_token");
const storedUser = localStorage.getItem("user");
if (storedToken) {
setToken(storedToken);
}
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (e) {
console.error("Failed to parse stored user:", e);
}
}
}, [devMode]);
// Sync token changes to axios headers
useEffect(() => {
setAuthToken(token);
}, [token]);
// Handle login (JWT format)
const login = (response: AuthResponse) => {
// JWT 格式: { accessToken, refreshToken, username, ... }
const accessToken = response.accessToken || "";
const refreshToken = response.refreshToken || "";
localStorage.setItem("access_token", accessToken);
localStorage.setItem("refresh_token", refreshToken);
const user: User = {
username: response.username || "",
role: "user", // 后端暂未返回 role默认为 user
};
localStorage.setItem("user", JSON.stringify(user));
setToken(accessToken);
setUser(user);
};
// Handle logout
const logout = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
setToken(null);
setUser(null);
setAuthToken(null);
};
return (
<AuthContext.Provider
value={{
token,
user,
isAuthenticated: !!token,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};
// useAuth hook is now exported from ./useAuth.ts for Fast Refresh compatibility

View File

@ -1,9 +0,0 @@
/**
* Providers - Unified Export
* 提供者统一导出
*/
export { AuthProvider } from "./AuthProvider";
export { useAuth } from "./useAuth";

View File

@ -1,19 +0,0 @@
/**
* useAuth Hook
* Auth context hook - separated for Fast Refresh compatibility
*/
import { useContext } from "react";
import { AuthContext } from "./AuthContext";
/**
* Hook to use auth context
*/
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View File

@ -0,0 +1,304 @@
'use client';
import { useEffect, useState } from 'react';
import { registryApi } from '@/lib/api';
import type { RegistryDTO, CreateRegistryRequest } from '@/lib/types';
import { Database, Plus, Trash2, Edit, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
export default function RegistriesPage() {
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingRegistry, setEditingRegistry] = useState<RegistryDTO | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateRegistryRequest>({
name: '',
url: '',
username: '',
password: '',
description: '',
insecure: false,
isShared: false,
});
const fetchRegistries = async () => {
try {
const response = await registryApi.list();
// API returns array directly or { registries: [] }
const data = response.data;
if (Array.isArray(data)) {
setRegistries(data);
} else if (data && data.registries) {
setRegistries(data.registries);
} else {
setRegistries([]);
}
} catch (error) {
console.error('Failed to fetch registries:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchRegistries();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const dataToSend = {
...formData,
username: formData.username || undefined,
password: formData.password || undefined,
};
if (editingRegistry) {
await registryApi.update(editingRegistry.id, dataToSend);
} else {
await registryApi.create(dataToSend);
}
setShowForm(false);
setEditingRegistry(null);
setFormData({ name: '', url: '', username: '', password: '', description: '', insecure: false, isShared: false });
fetchRegistries();
alert(editingRegistry ? 'Registry updated successfully!' : 'Registry connected successfully!');
} catch (error: unknown) {
console.error('Failed to save registry:', error);
let errorMessage = 'Failed to save registry';
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string; message?: string } } };
if (axiosError.response?.data?.message) {
errorMessage = axiosError.response.data.message;
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
}
alert('Error: ' + errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (registry: RegistryDTO) => {
setEditingRegistry(registry);
setFormData({
name: registry.name,
url: registry.url,
username: registry.username || '',
description: registry.description || '',
insecure: registry.insecure,
isShared: registry.isShared,
});
setShowForm(true);
};
const handleDelete = async (registryId: string) => {
if (!confirm('Are you sure you want to delete this registry?')) return;
try {
await registryApi.delete(registryId);
fetchRegistries();
} catch (error) {
console.error('Failed to delete registry:', error);
alert('Failed to delete registry');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">Registries</h1>
<p className="text-[var(--muted-foreground)]">Manage OCI/Harbor registries</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingRegistry(null);
setFormData({ name: '', url: '', username: '', password: '', description: '', insecure: false, isShared: false });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Registry
</button>
</div>
{/* 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)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingRegistry ? 'Edit Registry' : 'Add Registry'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">URL</label>
<input
type="text"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
className="input"
placeholder="https://harbor.example.com"
required
/>
</div>
<div>
<label className="label">Username (optional)</label>
<input
type="text"
value={formData.username || ''}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="input"
/>
</div>
<div>
<label className="label">Password (optional)</label>
<input
type="password"
value={formData.password || ''}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={2}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="insecure"
checked={formData.insecure || false}
onChange={(e) => setFormData({ ...formData, insecure: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="insecure" className="text-sm text-[var(--foreground)]">
Insecure (HTTP)
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isShared"
checked={formData.isShared || false}
onChange={(e) => setFormData({ ...formData, isShared: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isShared" className="text-sm text-[var(--foreground)]">
Shared
</label>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingRegistry(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isSubmitting ? 'Saving...' : (editingRegistry ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Registries List */}
{registries.length === 0 ? (
<div className="card text-center py-12">
<Database className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No registries configured</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Add your first registry
</button>
</div>
) : (
<div className="grid gap-4">
{registries.map((registry) => (
<div key={registry.id} className="card flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
<Database className="w-6 h-6 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{registry.name}</h3>
{registry.isShared && (
<span className="badge badge-info">Shared</span>
)}
{registry.insecure && (
<span className="badge badge-warning">Insecure</span>
)}
</div>
<p className="text-sm text-[var(--muted-foreground)]">{registry.url}</p>
{registry.description && (
<p className="text-sm text-[var(--muted-foreground)] mt-1">{registry.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{registry.username ? (
<span title="Credentials configured"><CheckCircle className="w-4 h-4 text-green-500" /></span>
) : (
<span title="No credentials"><AlertCircle className="w-4 h-4 text-yellow-500" /></span>
)}
<button
onClick={() => handleEdit(registry)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(registry.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,182 +0,0 @@
/**
* Application Routes Configuration
* 应用路由配置
*/
import { Routes, Route, Navigate } from "react-router-dom";
import { ProtectedRoute } from "./RouteGuard";
import AppShell from "@/shared/components/layout/AppShell";
import { getPageInfo, type NavItem } from "../constants/navigation";
import { useLocation } from "react-router-dom";
import type { AuthResponse } from "@/api";
// Feature pages
import AuthPage from "@/features/auth/pages/AuthPage";
import HomePage from "@/features/home/pages/HomePage";
import ClusterConfigPage from "@/features/configuration/clusters/pages/ClusterConfigPage";
import RegistryConfigPage from "@/features/configuration/registries/pages/RegistryConfigPage";
import ArtifactBrowserPage from "@/features/artifact/registries/pages/ArtifactBrowserPage";
import InstancesManagementPage from "@/features/artifact/instances/pages/InstancesManagementPage";
import MonitoringClustersPage from "@/features/monitoring/clusters/pages/MonitoringClustersPage";
import { ApiTest } from "@/components/ApiTest";
interface AppRoutesProps {
isAuthenticated: boolean;
userName?: string;
navItems: NavItem[];
onLogin: (tokens: AuthResponse) => void;
onLogout: () => void;
}
/**
* Main application routes
*/
export const AppRoutes = ({
isAuthenticated,
userName = "User",
navItems,
onLogin,
onLogout,
}: AppRoutesProps) => {
const location = useLocation();
const pageInfo = getPageInfo(location.pathname);
return (
<Routes>
{/* Public route - Authentication page */}
<Route
path="/"
element={
isAuthenticated ? (
<Navigate to="/home" replace />
) : (
<AuthPage onLogin={onLogin} />
)
}
/>
{/* Protected routes - wrapped in AppShell */}
<Route
path="/home"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<HomePage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/configuration/clusters"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<ClusterConfigPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/configuration/registries"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<RegistryConfigPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/artifact/registries"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<ArtifactBrowserPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/artifact/instances"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<InstancesManagementPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/monitoring/clusters"
element={
<ProtectedRoute isAuthenticated={isAuthenticated}>
<AppShell
title={pageInfo.title}
icon={pageInfo.icon}
userName={userName}
navItems={navItems}
onSignOut={onLogout}
>
<MonitoringClustersPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* API Test page - Public for testing */}
<Route path="/api-test" element={<ApiTest />} />
{/* Legacy path compatibility - redirects */}
<Route path="/config" element={<Navigate to="/configuration/clusters" replace />} />
<Route path="/config/cluster" element={<Navigate to="/configuration/clusters" replace />} />
<Route path="/config/clusters" element={<Navigate to="/configuration/clusters" replace />} />
<Route path="/config/app" element={<Navigate to="/configuration/registries" replace />} />
<Route path="/config/registry" element={<Navigate to="/configuration/registries" replace />} />
<Route path="/config/registries" element={<Navigate to="/configuration/registries" replace />} />
<Route path="/artifact/registry" element={<Navigate to="/artifact/registries" replace />} />
<Route path="/artifact/instance" element={<Navigate to="/artifact/instances" replace />} />
<Route path="/monitor" element={<Navigate to="/monitoring/clusters" replace />} />
<Route path="/cluster" element={<Navigate to="/monitoring/clusters" replace />} />
<Route path="/cluster/monitor" element={<Navigate to="/monitoring/clusters" replace />} />
<Route path="/registry" element={<Navigate to="/artifact/registries" replace />} />
<Route path="/register" element={<Navigate to="/" replace />} />
</Routes>
);
};

View File

@ -1,39 +0,0 @@
/**
* Route Guard Component
* 路由守卫组件 - 处理认证和授权
*/
import { Navigate } from "react-router-dom";
import type { ReactNode } from "react";
interface RouteGuardProps {
isAuthenticated: boolean;
redirectTo?: string;
children: ReactNode;
}
/**
* Protected route wrapper
* Redirects to auth page if not authenticated
*/
export const ProtectedRoute = ({
isAuthenticated,
redirectTo = "/",
children
}: RouteGuardProps) => {
return isAuthenticated ? <>{children}</> : <Navigate to={redirectTo} replace />;
};
/**
* Public route wrapper
* Redirects to home if already authenticated
*/
export const PublicRoute = ({
isAuthenticated,
redirectTo = "/home",
children
}: RouteGuardProps) => {
return !isAuthenticated ? <>{children}</> : <Navigate to={redirectTo} replace />;
};

View File

@ -0,0 +1,383 @@
'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';
export default function StoragePage() {
const [storages, setStorages] = useState<StorageDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingStorage, setEditingStorage] = useState<StorageDTO | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateStorageRequest>({
name: '',
type: 'nfs',
description: '',
is_default: false,
is_shared: false,
});
const fetchStorages = async () => {
try {
const response = await storageApi.list();
setStorages(response.data || []);
} catch (error) {
console.error('Failed to fetch storages:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchStorages();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
if (editingStorage) {
await storageApi.update(editingStorage.id, formData as UpdateStorageRequest);
} else {
await storageApi.create(formData);
}
setShowForm(false);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
fetchStorages();
alert(editingStorage ? 'Storage backend updated successfully!' : 'Storage backend created successfully!');
} catch (error) {
console.error('Failed to save storage:', error);
alert('Failed to save storage backend: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (storage: StorageDTO) => {
setEditingStorage(storage);
setFormData({
name: storage.name,
type: storage.type,
description: storage.description || '',
is_default: storage.is_default,
is_shared: storage.is_shared,
nfs: storage.config.nfs,
pv: storage.config.pv,
hostPath: storage.config.hostPath,
});
setShowForm(true);
};
const handleDelete = async (storageId: string) => {
if (!confirm('Are you sure you want to delete this storage backend?')) return;
try {
await storageApi.delete(storageId);
fetchStorages();
} catch (error) {
console.error('Failed to delete storage:', error);
alert('Failed to delete storage backend');
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'nfs':
return <Server className="w-6 h-6 text-blue-500" />;
case 'pv':
return <HardDrive className="w-6 h-6 text-purple-500" />;
case 'hostPath':
return <Folder className="w-6 h-6 text-green-500" />;
default:
return <HardDrive className="w-6 h-6 text-gray-500" />;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'nfs':
return 'NFS';
case 'pv':
return 'Persistent Volume';
case 'hostPath':
return 'Host Path';
default:
return type;
}
};
const renderConfig = (storage: StorageDTO) => {
if (storage.config.nfs) {
return (
<div className="text-sm text-[var(--muted-foreground)]">
<span className="font-medium">Server:</span> {storage.config.nfs.server}
<br />
<span className="font-medium">Path:</span> {storage.config.nfs.path}
</div>
);
}
if (storage.config.pv) {
return (
<div className="text-sm text-[var(--muted-foreground)]">
<span className="font-medium">StorageClass:</span> {storage.config.pv.storageClassName}
<br />
<span className="font-medium">Capacity:</span> {storage.config.pv.capacity}
</div>
);
}
if (storage.config.hostPath) {
return (
<div className="text-sm text-[var(--muted-foreground)]">
<span className="font-medium">Path:</span> {storage.config.hostPath.path}
</div>
);
}
return null;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">Storage Backends</h1>
<p className="text-[var(--muted-foreground)]">Manage NFS, PV, and HostPath storage</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
<Plus className="w-4 h-4" />
Add Storage
</button>
</div>
{/* 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)]">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingStorage ? 'Edit Storage Backend' : 'Add Storage Backend'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input"
required
/>
</div>
<div>
<label className="label">Type</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="input"
>
<option value="nfs">NFS</option>
<option value="pv">Persistent Volume</option>
<option value="hostPath">Host Path</option>
</select>
</div>
{/* NFS Config */}
{formData.type === 'nfs' && (
<>
<div>
<label className="label">NFS Server</label>
<input
type="text"
value={formData.nfs?.server || ''}
onChange={(e) => setFormData({ ...formData, nfs: { ...formData.nfs, server: e.target.value, path: formData.nfs?.path || '' } })}
className="input"
placeholder="nfs.example.com"
/>
</div>
<div>
<label className="label">NFS Path</label>
<input
type="text"
value={formData.nfs?.path || ''}
onChange={(e) => setFormData({ ...formData, nfs: { ...formData.nfs, server: formData.nfs?.server || '', path: e.target.value } })}
className="input"
placeholder="/exports/data"
/>
</div>
</>
)}
{/* PV Config */}
{formData.type === 'pv' && (
<>
<div>
<label className="label">Storage Class Name</label>
<input
type="text"
value={formData.pv?.storageClassName || ''}
onChange={(e) => setFormData({ ...formData, pv: { ...formData.pv, storageClassName: e.target.value, capacity: formData.pv?.capacity || '10Gi', accessModes: formData.pv?.accessModes || ['ReadWriteOnce'] } })}
className="input"
placeholder="standard"
/>
</div>
<div>
<label className="label">Capacity</label>
<input
type="text"
value={formData.pv?.capacity || ''}
onChange={(e) => setFormData({ ...formData, pv: { ...formData.pv, storageClassName: formData.pv?.storageClassName || '', capacity: e.target.value, accessModes: formData.pv?.accessModes || ['ReadWriteOnce'] } })}
className="input"
placeholder="10Gi"
/>
</div>
</>
)}
{/* HostPath Config */}
{formData.type === 'hostPath' && (
<div>
<label className="label">Host Path</label>
<input
type="text"
value={formData.hostPath?.path || ''}
onChange={(e) => setFormData({ ...formData, hostPath: { path: e.target.value } })}
className="input"
placeholder="/mnt/data"
/>
</div>
)}
<div>
<label className="label">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={2}
/>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isDefault"
checked={formData.is_default || false}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isDefault" className="text-sm text-[var(--foreground)]">
Default
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isShared"
checked={formData.is_shared || false}
onChange={(e) => setFormData({ ...formData, is_shared: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="isShared" className="text-sm text-[var(--foreground)]">
Shared
</label>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingStorage(null);
}}
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--secondary)]"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
{isSubmitting ? 'Saving...' : (editingStorage ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
{/* Storages List */}
{storages.length === 0 ? (
<div className="card text-center py-12">
<HardDrive className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)]">No storage backends configured</p>
<button
onClick={() => setShowForm(true)}
className="mt-4 text-[var(--primary)] hover:underline"
>
Add your first storage backend
</button>
</div>
) : (
<div className="grid gap-4">
{storages.map((storage) => (
<div key={storage.id} className="card flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-[var(--primary)]/10">
{getTypeIcon(storage.type)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{storage.name}</h3>
<span className="badge">{getTypeLabel(storage.type)}</span>
{storage.is_default && (
<span className="badge badge-info">Default</span>
)}
{storage.is_shared && (
<span className="badge badge-success">Shared</span>
)}
</div>
{renderConfig(storage)}
{storage.description && (
<p className="text-sm text-[var(--muted-foreground)] mt-1">{storage.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(storage)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(storage.id)}
className="p-2 rounded-lg hover:bg-[var(--secondary)] text-[var(--muted-foreground)] hover:text-[#ef4444]"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,435 @@
'use client';
import { useEffect, useState } from 'react';
import { valuesTemplateApi, chartReferenceApi } from '@/lib/api';
import type { ValuesTemplateDTO, CreateValuesTemplateRequest, UpdateValuesTemplateRequest, ChartReferenceDTO } from '@/lib/types';
import { FileText, Plus, Trash2, Edit, History, RotateCcw, Search, Package, Tag } from 'lucide-react';
export default function TemplatesPage() {
const [templates, setTemplates] = useState<ValuesTemplateDTO[]>([]);
const [chartRefs, setChartRefs] = useState<ChartReferenceDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ValuesTemplateDTO | null>(null);
const [selectedChartRef, setSelectedChartRef] = useState<string>('');
const [historyTemplates, setHistoryTemplates] = useState<ValuesTemplateDTO[]>([]);
const [historyName, setHistoryName] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [formData, setFormData] = useState<CreateValuesTemplateRequest>({
chart_reference_id: '',
name: '',
description: '',
values_yaml: '',
is_default: false,
});
const fetchTemplates = async () => {
try {
const response = await valuesTemplateApi.list();
setTemplates(response.data || []);
} catch (error) {
console.error('Failed to fetch templates:', error);
} finally {
setIsLoading(false);
}
};
const fetchChartRefs = async () => {
try {
const response = await chartReferenceApi.list();
setChartRefs(response.data || []);
} catch (error) {
console.error('Failed to fetch chart references:', error);
}
};
useEffect(() => {
fetchTemplates();
fetchChartRefs();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingTemplate) {
await valuesTemplateApi.update(editingTemplate.id, formData as UpdateValuesTemplateRequest);
} else {
await valuesTemplateApi.create(formData);
}
setShowForm(false);
setEditingTemplate(null);
setFormData({ chart_reference_id: '', name: '', description: '', values_yaml: '', is_default: false });
fetchTemplates();
} catch (error) {
console.error('Failed to save template:', error);
alert('Failed to save values template');
}
};
const handleEdit = (template: ValuesTemplateDTO) => {
setEditingTemplate(template);
setFormData({
chart_reference_id: template.chart_reference_id,
name: template.name,
description: template.description || '',
values_yaml: template.values_yaml,
is_default: template.is_default,
});
setShowForm(true);
};
const handleDelete = async (templateId: string) => {
if (!confirm('Are you sure you want to delete this template? All versions will be deleted.')) return;
try {
await valuesTemplateApi.delete(templateId);
fetchTemplates();
} catch (error) {
console.error('Failed to delete template:', error);
alert('Failed to delete template');
}
};
const handleViewHistory = async (chartRefId: string, name: string) => {
try {
const response = await valuesTemplateApi.getHistory(chartRefId, name);
setHistoryTemplates(response.data || []);
setSelectedChartRef(chartRefId);
setHistoryName(name);
setShowHistory(true);
} catch (error) {
console.error('Failed to fetch history:', error);
alert('Failed to fetch template history');
}
};
const handleRollback = async (templateId: string) => {
if (!confirm('Are you sure you want to rollback to this version?')) return;
try {
await valuesTemplateApi.rollback(selectedChartRef, templateId);
setShowHistory(false);
fetchTemplates();
alert('Rollback successful');
} catch (error) {
console.error('Failed to rollback:', error);
alert('Failed to rollback template');
}
};
const getChartRefName = (chartRefId: string) => {
const chartRef = chartRefs.find(cr => cr.id === chartRefId);
return chartRef?.chart_name || chartRefId;
};
// Group templates by chart_reference_id and name, show only latest version
const latestTemplates = templates.reduce((acc, template) => {
const key = `${template.chart_reference_id}-${template.name}`;
if (!acc[key] || template.version > acc[key].version) {
acc[key] = template;
}
return acc;
}, {} as Record<string, ValuesTemplateDTO>);
const filteredTemplates = Object.values(latestTemplates).filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
getChartRefName(t.chart_reference_id).toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</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)]">Values Templates</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Manage Helm values templates with version control
</p>
</div>
<button
onClick={() => {
setShowForm(true);
setEditingTemplate(null);
setFormData({ chart_reference_id: '', name: '', description: '', values_yaml: '', is_default: false });
}}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" />
Add Template
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)]"
/>
</div>
{/* Form Modal */}
{showForm && (
<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-2xl border border-[var(--border)] max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-[var(--foreground)] mb-4">
{editingTemplate ? `Edit Template (v${editingTemplate.version}) - Creating new version` : 'Add Values Template'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Chart Reference
</label>
<select
value={formData.chart_reference_id}
onChange={(e) => setFormData({ ...formData, chart_reference_id: e.target.value })}
required
disabled={!!editingTemplate}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] disabled:opacity-50"
>
<option value="">Select a chart reference</option>
{chartRefs.map((cr) => (
<option key={cr.id} value={cr.id}>{cr.chart_name} ({cr.repository})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Template Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., production, development, default"
required
disabled={!!editingTemplate}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description..."
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Values YAML
</label>
<textarea
value={formData.values_yaml}
onChange={(e) => setFormData({ ...formData, values_yaml: e.target.value })}
placeholder="replicaCount: 1&#10;image: &#10; repository: nginx&#10; tag: latest"
required
rows={12}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_default"
checked={formData.is_default}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="w-4 h-4"
/>
<label htmlFor="is_default" className="text-sm text-[var(--foreground)]">
Set as default template
</label>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingTemplate(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
type="submit"
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{editingTemplate ? 'Create New Version' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* History Modal */}
{showHistory && (
<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-2xl border border-[var(--border)] max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold text-[var(--foreground)] mb-2">
Version History
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
{historyName} - {getChartRefName(selectedChartRef)}
</p>
<div className="space-y-3">
{historyTemplates.map((template) => (
<div
key={template.id}
className="p-4 border border-[var(--border)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-[var(--primary)]" />
<span className="font-medium text-[var(--foreground)]">Version {template.version}</span>
{template.is_default && (
<span className="px-2 py-0.5 text-xs bg-[var(--primary)] text-[var(--primary-foreground)] rounded">
Default
</span>
)}
</div>
<button
onClick={() => handleRollback(template.id)}
className="flex items-center gap-1 px-3 py-1 text-sm border border-[var(--border)] text-[var(--foreground)] rounded hover:bg-[var(--secondary)] transition-colors"
>
<RotateCcw className="w-3 h-3" />
Rollback
</button>
</div>
<div className="text-xs text-[var(--muted-foreground)] mb-2">
Created: {new Date(template.createdAt).toLocaleString()}
</div>
<pre className="text-xs text-[var(--foreground)] bg-[var(--background)] p-2 rounded overflow-x-auto max-h-32">
{template.values_yaml}
</pre>
</div>
))}
{historyTemplates.length === 0 && (
<p className="text-center text-[var(--muted-foreground)] py-4">
No version history found
</p>
)}
</div>
<div className="mt-4">
<button
onClick={() => setShowHistory(false)}
className="w-full px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
)}
{/* Table */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full">
<thead className="bg-[var(--secondary)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Template
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Chart
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Version
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Default
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Description
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{filteredTemplates.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--muted-foreground)]">
No templates found
</td>
</tr>
) : (
filteredTemplates.map((template) => (
<tr key={`${template.chart_reference_id}-${template.name}`} className="hover:bg-[var(--secondary)] transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-[var(--primary)]" />
<span className="font-medium text-[var(--foreground)]">{template.name}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-[var(--muted-foreground)]">
<Package className="w-3 h-3" />
{getChartRefName(template.chart_reference_id)}
</div>
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 text-xs bg-[var(--secondary)] text-[var(--foreground)] rounded">
v{template.version}
</span>
</td>
<td className="px-4 py-3">
{template.is_default ? (
<span className="px-2 py-1 text-xs bg-green-500/20 text-green-500 rounded">Yes</span>
) : (
<span className="text-[var(--muted-foreground)]">-</span>
)}
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)] text-sm">
{template.description || '-'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleViewHistory(template.chart_reference_id, template.name)}
className="p-1 hover:bg-[var(--secondary)] rounded"
title="Version History"
>
<History className="w-4 h-4 text-[var(--muted-foreground)]" />
</button>
<button
onClick={() => handleEdit(template)}
className="p-1 hover:bg-[var(--secondary)] rounded"
title="Edit (Create New Version)"
>
<Edit className="w-4 h-4 text-[var(--muted-foreground)]" />
</button>
<button
onClick={() => handleDelete(template.id)}
className="p-1 hover:bg-[var(--secondary)] rounded"
title="Delete"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}