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:
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
334
frontend/src/app/admin/users/page.tsx
Normal file
334
frontend/src/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
401
frontend/src/app/admin/workspaces/page.tsx
Normal file
401
frontend/src/app/admin/workspaces/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
frontend/src/app/chart-references/page.tsx
Normal file
335
frontend/src/app/chart-references/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
523
frontend/src/app/charts/page.tsx
Normal file
523
frontend/src/app/charts/page.tsx
Normal 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 replicaCount: 2 image: 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';
|
||||
}
|
||||
348
frontend/src/app/clusters/page.tsx
Normal file
348
frontend/src/app/clusters/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" /> };
|
||||
};
|
||||
|
||||
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
142
frontend/src/app/globals.css
Normal file
142
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
|
||||
25
frontend/src/app/layout.tsx
Normal file
25
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
frontend/src/app/login/layout.tsx
Normal file
13
frontend/src/app/login/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
frontend/src/app/login/page.tsx
Normal file
124
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
frontend/src/app/monitoring/page.tsx
Normal file
278
frontend/src/app/monitoring/page.tsx
Normal 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
174
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Providers - Unified Export
|
||||
* 提供者统一导出
|
||||
*/
|
||||
|
||||
export { AuthProvider } from "./AuthProvider";
|
||||
export { useAuth } from "./useAuth";
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
304
frontend/src/app/registries/page.tsx
Normal file
304
frontend/src/app/registries/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 />;
|
||||
};
|
||||
|
||||
|
||||
383
frontend/src/app/storage/page.tsx
Normal file
383
frontend/src/app/storage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
435
frontend/src/app/templates/page.tsx
Normal file
435
frontend/src/app/templates/page.tsx
Normal 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 image: repository: nginx 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user