- 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
670 lines
16 KiB
Svelte
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>
|