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

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

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

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

View File

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

View File

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