test/frontend/src/routes/admin/+page.svelte
Z User c32a95fc91 fix: Update frontend to Svelte 5 syntax
- Use $state() runes for reactive state
- Use $derived() for computed values
- Update event handlers (onclick instead of on:click)
- Update package.json for Svelte 5 dependencies
- Rename stores.js to stores.svelte.js for runes support
2026-03-24 03:03:21 +00:00

670 lines
16 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { getUser, initializeAuth } from '$lib/stores.svelte';
let stats = $state({ total_users: 0, total_endpoints: 0, total_messages: 0, active_endpoints: 0 });
let users = $state([]);
let endpoints = $state([]);
let showUserModal = $state(false);
let editingUser = $state(null);
let userForm = $state({ email: '', username: '', role: 'user', is_active: true });
let showEndpointModal = $state(false);
let editingEndpoint = $state(null);
let endpointForm = $state({
name: '',
endpoint_type: 'openai',
base_url: '',
api_key: '',
model_name: '',
is_active: true,
is_default: false
});
let activeTab = $state('stats');
let loading = $state(true);
let user = $derived(getUser());
onMount(async () => {
await initializeAuth();
if (!user || user.role !== 'admin') {
goto('/chat');
return;
}
await loadData();
});
async function loadData() {
loading = true;
try {
const [statsData, usersData, endpointsData] = await Promise.all([
api.getAdminStats(),
api.getUsers(),
api.getAdminEndpoints()
]);
stats = statsData;
users = usersData;
endpoints = endpointsData;
} catch (e) {
console.error('Failed to load admin data:', e);
} finally {
loading = false;
}
}
function openUserModal(user = null) {
editingUser = user;
if (user) {
userForm = {
email: user.email,
username: user.username,
role: user.role,
is_active: user.is_active
};
} else {
userForm = { email: '', username: '', role: 'user', is_active: true };
}
showUserModal = true;
}
async function saveUser() {
try {
if (editingUser) {
await api.updateUser(editingUser.id, userForm);
}
showUserModal = false;
await loadData();
} catch (e) {
alert('Failed to save user: ' + e.message);
}
}
async function deleteUser(user) {
if (!confirm(`Delete user "${user.username}"?`)) return;
try {
await api.deleteUser(user.id);
await loadData();
} catch (e) {
alert('Failed to delete user: ' + e.message);
}
}
function openEndpointModal(endpoint = null) {
editingEndpoint = endpoint;
if (endpoint) {
endpointForm = {
name: endpoint.name,
endpoint_type: endpoint.endpoint_type,
base_url: endpoint.base_url,
api_key: endpoint.api_key || '',
model_name: endpoint.model_name,
is_active: endpoint.is_active,
is_default: endpoint.is_default
};
} else {
endpointForm = {
name: '',
endpoint_type: 'openai',
base_url: '',
api_key: '',
model_name: '',
is_active: true,
is_default: false
};
}
showEndpointModal = true;
}
async function saveEndpoint() {
try {
if (editingEndpoint) {
await api.updateEndpoint(editingEndpoint.id, endpointForm);
} else {
await api.createEndpoint(endpointForm);
}
showEndpointModal = false;
await loadData();
} catch (e) {
alert('Failed to save endpoint: ' + e.message);
}
}
async function deleteEndpoint(endpoint) {
if (!confirm(`Delete endpoint "${endpoint.name}"?`)) return;
try {
await api.deleteEndpoint(endpoint.id);
await loadData();
} catch (e) {
alert('Failed to delete endpoint: ' + e.message);
}
}
</script>
<div class="admin-page">
<h1>Admin Panel</h1>
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'stats'}
onclick={() => activeTab = 'stats'}
>
Dashboard
</button>
<button
class="tab"
class:active={activeTab === 'users'}
onclick={() => activeTab = 'users'}
>
Users
</button>
<button
class="tab"
class:active={activeTab === 'endpoints'}
onclick={() => activeTab = 'endpoints'}
>
AI Endpoints
</button>
</div>
{#if loading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else}
{#if activeTab === 'stats'}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.total_users}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.total_endpoints}</div>
<div class="stat-label">AI Endpoints</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.active_endpoints}</div>
<div class="stat-label">Active Endpoints</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.total_messages}</div>
<div class="stat-label">Total Messages</div>
</div>
</div>
{/if}
{#if activeTab === 'users'}
<div class="section-header">
<h2>Users</h2>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as userRow}
<tr>
<td>{userRow.id}</td>
<td>{userRow.username}</td>
<td>{userRow.email}</td>
<td>
<span class="badge" class:admin={userRow.role === 'admin'}>
{userRow.role}
</span>
</td>
<td>
<span class="status" class:active={userRow.is_active}>
{userRow.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>{new Date(userRow.created_at).toLocaleDateString()}</td>
<td>
<div class="actions">
<button class="btn btn-secondary btn-sm" onclick={() => openUserModal(userRow)}>
Edit
</button>
{#if userRow.role !== 'admin'}
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(userRow)}>
Delete
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if activeTab === 'endpoints'}
<div class="section-header">
<h2>AI Endpoints</h2>
<button class="btn btn-primary" onclick={() => openEndpointModal()}>
+ Add Endpoint
</button>
</div>
<div class="endpoints-grid">
{#each endpoints as endpoint}
<div class="endpoint-card">
<div class="endpoint-header">
<h3>{endpoint.name}</h3>
<div class="endpoint-badges">
{#if endpoint.is_default}
<span class="badge default">Default</span>
{/if}
{#if endpoint.is_active}
<span class="badge active">Active</span>
{:else}
<span class="badge inactive">Inactive</span>
{/if}
</div>
</div>
<div class="endpoint-details">
<p><strong>Type:</strong> {endpoint.endpoint_type}</p>
<p><strong>Model:</strong> {endpoint.model_name}</p>
<p><strong>URL:</strong> {endpoint.base_url}</p>
</div>
<div class="endpoint-actions">
<button class="btn btn-secondary btn-sm" onclick={() => openEndpointModal(endpoint)}>
Edit
</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteEndpoint(endpoint)}>
Delete
</button>
</div>
</div>
{:else}
<p class="no-data">No endpoints configured. Add one to get started.</p>
{/each}
</div>
{/if}
{/if}
</div>
{#if showUserModal}
<div class="modal-overlay" onclick={() => showUserModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h3>{editingUser ? 'Edit User' : 'New User'}</h3>
<form onsubmit={(e) => { e.preventDefault(); saveUser(); }}>
<div class="form-group">
<label class="label">Email</label>
<input type="email" class="input" bind:value={userForm.email} required />
</div>
<div class="form-group">
<label class="label">Username</label>
<input type="text" class="input" bind:value={userForm.username} required />
</div>
<div class="form-group">
<label class="label">Role</label>
<select class="input" bind:value={userForm.role}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" bind:checked={userForm.is_active} />
Active
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick={() => showUserModal = false}>
Cancel
</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
{/if}
{#if showEndpointModal}
<div class="modal-overlay" onclick={() => showEndpointModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h3>{editingEndpoint ? 'Edit Endpoint' : 'Add AI Endpoint'}</h3>
<form onsubmit={(e) => { e.preventDefault(); saveEndpoint(); }}>
<div class="form-group">
<label class="label">Name</label>
<input type="text" class="input" bind:value={endpointForm.name} placeholder="My OpenAI Endpoint" required />
</div>
<div class="form-group">
<label class="label">Type</label>
<select class="input" bind:value={endpointForm.endpoint_type}>
<option value="openai">OpenAI Compatible</option>
<option value="ollama">Ollama</option>
</select>
</div>
<div class="form-group">
<label class="label">Base URL</label>
<input
type="text"
class="input"
bind:value={endpointForm.base_url}
placeholder="https://api.openai.com/v1"
required
/>
</div>
<div class="form-group">
<label class="label">API Key (optional for Ollama)</label>
<input
type="password"
class="input"
bind:value={endpointForm.api_key}
placeholder="sk-..."
/>
</div>
<div class="form-group">
<label class="label">Model Name</label>
<input
type="text"
class="input"
bind:value={endpointForm.model_name}
placeholder="gpt-3.5-turbo or llama2"
required
/>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" bind:checked={endpointForm.is_active} />
Active
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={endpointForm.is_default} />
Default
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick={() => showEndpointModal = false}>
Cancel
</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.admin-page {
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin-bottom: 1.5rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
}
.tab:hover {
background: var(--surface-hover);
color: var(--text);
}
.tab.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.loading {
display: flex;
justify-content: center;
padding: 3rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
text-align: center;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
font-size: 1.25rem;
}
.table-container {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--background);
font-weight: 600;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
background: var(--surface-hover);
color: var(--text-muted);
}
.badge.admin {
background: var(--primary);
color: white;
}
.badge.active {
background: var(--success);
color: white;
}
.badge.inactive {
background: var(--danger);
color: white;
}
.badge.default {
background: var(--warning);
color: black;
}
.status {
font-size: 0.75rem;
color: var(--text-muted);
}
.status.active {
color: var(--success);
}
.actions {
display: flex;
gap: 0.5rem;
}
.endpoints-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.endpoint-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.25rem;
}
.endpoint-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.endpoint-header h3 {
font-size: 1rem;
margin: 0;
}
.endpoint-badges {
display: flex;
gap: 0.25rem;
}
.endpoint-details {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.endpoint-details p {
margin-bottom: 0.25rem;
}
.endpoint-actions {
display: flex;
gap: 0.5rem;
}
.no-data {
color: var(--text-muted);
font-style: italic;
grid-column: 1 / -1;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin-bottom: 1rem;
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
}
.checkbox-label input {
accent-color: var(--primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>