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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user