- Normalize OpenAI array-format content to string to fix connection reset - Add error.log with rotating handler for proxy and stream errors - Add global unhandled exception handler returning JSON 500 - Write OLLAMA_URL/DEFAULT_MODEL env vars to DB on startup (reset on restart) - Add extra_hosts to docker-compose.yml for host.docker.internal on Linux - Show warning in admin UI when Ollama URL is unreachable - Return reachable: true/false from /api/ollama-models endpoint
504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import axios from 'axios';
|
|
import './styles.css';
|
|
|
|
const displayKey = (prefix) => prefix ? `${prefix}••••••••` : '••••••••••••';
|
|
|
|
function authHeaders(token) {
|
|
return { Authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
const fmtK = (n) => { const k = n / 1000; return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; };
|
|
|
|
function QuotaBar({ used, limit, isToken = false, since = null }) {
|
|
const fmt = isToken ? fmtK : (n) => n.toLocaleString('de-DE');
|
|
const sinceLabel = since
|
|
? new Date(since).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
|
: null;
|
|
|
|
if (limit == null) return (
|
|
<div className="quota-cell">
|
|
<span className="quota-unlimited">∞</span>
|
|
{sinceLabel && <span className="quota-since">seit {sinceLabel}</span>}
|
|
</div>
|
|
);
|
|
|
|
const pct = Math.min(100, (used / limit) * 100);
|
|
const color = pct >= 90 ? '#e74c3c' : pct >= 70 ? '#e67e22' : '#27ae60';
|
|
return (
|
|
<div className="quota-cell">
|
|
<span className="quota-label">{fmt(used)} / {fmt(limit)}</span>
|
|
<div className="progress-bar">
|
|
<div className="progress-fill" style={{ width: `${pct}%`, backgroundColor: color }} />
|
|
</div>
|
|
{sinceLabel && <span className="quota-since">seit {sinceLabel}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Login({ onLogin }) {
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState(null);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
try {
|
|
await axios.get('/api/api-keys', { headers: authHeaders(password) });
|
|
sessionStorage.setItem('admin_password', password);
|
|
onLogin(password);
|
|
} catch {
|
|
setError('Ungültiges Passwort.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="container">
|
|
<h1>Ollama Proxy Admin</h1>
|
|
<form onSubmit={handleSubmit} className="login-form">
|
|
<label>Passwort</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Admin-Passwort eingeben"
|
|
autoFocus
|
|
/>
|
|
{error && <div className="error">{error}</div>}
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const EMPTY_KEY_FORM = {
|
|
name: '', expires_at: '', daily_tokens: '', monthly_tokens: '', daily_requests: '', monthly_requests: '',
|
|
};
|
|
|
|
function SettingsSection({ password }) {
|
|
const [settings, setSettings] = useState(null);
|
|
const [availableModels, setAvailableModels] = useState([]);
|
|
const [modelsLoading, setModelsLoading] = useState(false);
|
|
const [ollamaReachable, setOllamaReachable] = useState(true);
|
|
const [proxyEndpoint, setProxyEndpoint] = useState(null);
|
|
const [saved, setSaved] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
|
|
const fetchModels = async (url, currentModel) => {
|
|
setModelsLoading(true);
|
|
try {
|
|
const res = await axios.get('/api/ollama-models', {
|
|
headers: authHeaders(password),
|
|
params: url ? { url } : {},
|
|
});
|
|
const { models, reachable } = res.data;
|
|
setOllamaReachable(reachable);
|
|
setAvailableModels(models);
|
|
if (models.length > 0 && !models.includes(currentModel)) {
|
|
setSettings(s => ({ ...s, default_model: models[0] }));
|
|
}
|
|
} catch {
|
|
setOllamaReachable(false);
|
|
setAvailableModels([]);
|
|
} finally {
|
|
setModelsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const headers = authHeaders(password);
|
|
Promise.all([
|
|
axios.get('/api/settings', { headers }),
|
|
axios.get('/api/proxy-info', { headers }),
|
|
]).then(([settingsRes, proxyRes]) => {
|
|
const s = settingsRes.data;
|
|
setSettings(s);
|
|
setProxyEndpoint(proxyRes.data.endpoint);
|
|
fetchModels(s.ollama_url, s.default_model);
|
|
}).catch(() => setError('Einstellungen konnten nicht geladen werden.'));
|
|
}, []);
|
|
|
|
const handleSave = async (e) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setSaved(false);
|
|
try {
|
|
await axios.put('/api/settings', settings, { headers: authHeaders(password) });
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch {
|
|
setError('Fehler beim Speichern.');
|
|
}
|
|
};
|
|
|
|
if (!settings) return <div>Laden...</div>;
|
|
|
|
return (
|
|
<section>
|
|
<h2>Einstellungen</h2>
|
|
<form onSubmit={handleSave} className="settings-form">
|
|
<div className="settings-row">
|
|
<label>Proxy-Endpunkt</label>
|
|
<span className="settings-value">
|
|
{proxyEndpoint ?? '…'}
|
|
<small> (Änderung erfordert Neustart)</small>
|
|
</span>
|
|
</div>
|
|
<div className="settings-row">
|
|
<label>Ollama-Endpunkt</label>
|
|
<div className="settings-input-wrap">
|
|
<input
|
|
type="url"
|
|
value={settings.ollama_url}
|
|
onChange={(e) => setSettings({ ...settings, ollama_url: e.target.value })}
|
|
onBlur={(e) => fetchModels(e.target.value, settings.default_model)}
|
|
placeholder="http://localhost:11434"
|
|
required
|
|
/>
|
|
{!ollamaReachable && !modelsLoading && (
|
|
<div className="warning">⚠ Ollama nicht erreichbar unter {settings.ollama_url}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="settings-row">
|
|
<label>Standard-Modell</label>
|
|
{modelsLoading ? (
|
|
<span className="settings-value">Lade Modelle…</span>
|
|
) : availableModels.length > 0 ? (
|
|
<select
|
|
value={settings.default_model}
|
|
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
|
|
>
|
|
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={settings.default_model}
|
|
onChange={(e) => setSettings({ ...settings, default_model: e.target.value })}
|
|
placeholder="llama3"
|
|
required
|
|
/>
|
|
)}
|
|
</div>
|
|
{error && <div className="error">{error}</div>}
|
|
{saved && <div className="success">Gespeichert.</div>}
|
|
<button type="submit">Speichern</button>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_password'));
|
|
const [apiKeys, setApiKeys] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [newKey, setNewKey] = useState(null);
|
|
const [form, setForm] = useState(EMPTY_KEY_FORM);
|
|
const [creating, setCreating] = useState(false);
|
|
const [editKey, setEditKey] = useState(null);
|
|
const [editForm, setEditForm] = useState({});
|
|
|
|
useEffect(() => {
|
|
if (!password) { setLoading(false); return; }
|
|
fetchApiKeys().finally(() => setLoading(false));
|
|
}, [password]);
|
|
|
|
const fetchApiKeys = async () => {
|
|
try {
|
|
const res = await axios.get('/api/api-keys', { headers: authHeaders(password) });
|
|
setApiKeys(res.data);
|
|
} catch {
|
|
setError('API-Keys konnten nicht geladen werden.');
|
|
}
|
|
};
|
|
|
|
const handleCreate = async (e) => {
|
|
e.preventDefault();
|
|
setCreating(true);
|
|
try {
|
|
const payload = { name: form.name };
|
|
if (form.expires_at) payload.expires_at = new Date(form.expires_at).toISOString();
|
|
if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens) * 1000;
|
|
if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens) * 1000;
|
|
if (form.daily_requests) payload.daily_requests = Number(form.daily_requests);
|
|
if (form.monthly_requests) payload.monthly_requests = Number(form.monthly_requests);
|
|
|
|
const res = await axios.post('/api/api-keys', payload, { headers: authHeaders(password) });
|
|
setNewKey(res.data.plaintext_key);
|
|
setForm(EMPTY_KEY_FORM);
|
|
await fetchApiKeys();
|
|
} catch {
|
|
setError('Fehler beim Erstellen des API-Keys.');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (id) => {
|
|
try {
|
|
await axios.put(`/api/api-keys/${id}/deactivate`, {}, { headers: authHeaders(password) });
|
|
await fetchApiKeys();
|
|
} catch {
|
|
setError('Fehler beim Deaktivieren.');
|
|
}
|
|
};
|
|
|
|
const handleActivate = async (id) => {
|
|
try {
|
|
await axios.put(`/api/api-keys/${id}/activate`, {}, { headers: authHeaders(password) });
|
|
await fetchApiKeys();
|
|
} catch {
|
|
setError('Fehler beim Aktivieren.');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id, name) => {
|
|
if (!window.confirm(`API-Key "${name}" wirklich löschen?`)) return;
|
|
try {
|
|
await axios.delete(`/api/api-keys/${id}`, { headers: authHeaders(password) });
|
|
if (editKey?.id === id) setEditKey(null);
|
|
await fetchApiKeys();
|
|
} catch {
|
|
setError('Fehler beim Löschen.');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (key) => {
|
|
setEditKey(key);
|
|
setEditForm({
|
|
daily_tokens: key.daily_tokens != null ? key.daily_tokens / 1000 : '',
|
|
monthly_tokens: key.monthly_tokens != null ? key.monthly_tokens / 1000 : '',
|
|
daily_requests: key.daily_requests ?? '',
|
|
monthly_requests: key.monthly_requests ?? '',
|
|
});
|
|
};
|
|
|
|
const handleSaveEdit = async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
const payload = {
|
|
daily_tokens: editForm.daily_tokens !== '' ? Number(editForm.daily_tokens) * 1000 : null,
|
|
monthly_tokens: editForm.monthly_tokens !== '' ? Number(editForm.monthly_tokens) * 1000 : null,
|
|
daily_requests: editForm.daily_requests !== '' ? Number(editForm.daily_requests) : null,
|
|
monthly_requests: editForm.monthly_requests !== '' ? Number(editForm.monthly_requests) : null,
|
|
};
|
|
await axios.patch(`/api/api-keys/${editKey.id}/quota`, payload, { headers: authHeaders(password) });
|
|
setEditKey(null);
|
|
await fetchApiKeys();
|
|
} catch {
|
|
setError('Fehler beim Speichern der Limits.');
|
|
}
|
|
};
|
|
|
|
const logout = () => {
|
|
sessionStorage.removeItem('admin_password');
|
|
setPassword(null);
|
|
};
|
|
|
|
if (!password) return <Login onLogin={setPassword} />;
|
|
if (loading) return <div>Laden...</div>;
|
|
if (error) return <div className="error">{error}</div>;
|
|
|
|
return (
|
|
<div className="container">
|
|
<div className="header">
|
|
<h1>Ollama Proxy Admin</h1>
|
|
<button onClick={logout}>Abmelden</button>
|
|
</div>
|
|
|
|
<SettingsSection password={password} />
|
|
|
|
<section>
|
|
<h2>Neuer API-Key</h2>
|
|
<form onSubmit={handleCreate} className="create-form">
|
|
<div className="edit-form">
|
|
<label className="create-name">
|
|
Name
|
|
<small> </small>
|
|
<input
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
required
|
|
placeholder="z. B. alice"
|
|
/>
|
|
</label>
|
|
<label className="create-date">
|
|
Ablaufdatum
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="date"
|
|
value={form.expires_at}
|
|
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
|
|
/>
|
|
</label>
|
|
<div className="create-btn-wrap">
|
|
<button type="submit" disabled={creating} className="btn-save">Erstellen</button>
|
|
</div>
|
|
</div>
|
|
<div className="edit-form">
|
|
<label>
|
|
Tokens/Tag (k)
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
placeholder="∞"
|
|
value={form.daily_tokens}
|
|
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Tokens/Monat (k)
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
placeholder="∞"
|
|
value={form.monthly_tokens}
|
|
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Requests/Tag
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
placeholder="∞"
|
|
value={form.daily_requests}
|
|
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Requests/Monat
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
placeholder="∞"
|
|
value={form.monthly_requests}
|
|
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</form>
|
|
{newKey && (
|
|
<div className="new-key-box">
|
|
<strong>Neuer Key (nur einmal sichtbar):</strong>
|
|
<code>{newKey}</code>
|
|
<button onClick={() => setNewKey(null)}>✕</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<h2>API Keys</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>Name</th>
|
|
<th>Key</th>
|
|
<th>Läuft ab</th>
|
|
<th className="th-quota">Tokens/Tag</th>
|
|
<th className="th-quota">Tokens/Monat</th>
|
|
<th className="th-quota">Req/Tag</th>
|
|
<th className="th-quota">Req/Monat</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{apiKeys.map(key => (
|
|
<tr key={key.id} className={editKey?.id === key.id ? 'row-editing' : ''}>
|
|
<td className="td-status">
|
|
<span className={key.is_active ? 'status-active' : 'status-inactive'} title={key.is_active ? 'Aktiv' : 'Inaktiv'}>●</span>
|
|
</td>
|
|
<td>{key.name}</td>
|
|
<td>{displayKey(key.key_prefix)}</td>
|
|
<td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td>
|
|
<td><QuotaBar used={key.tokens_used_today} limit={key.daily_tokens} isToken since={key.daily_reset_at} /></td>
|
|
<td><QuotaBar used={key.tokens_used_month} limit={key.monthly_tokens} isToken since={key.monthly_reset_at} /></td>
|
|
<td><QuotaBar used={key.requests_today} limit={key.daily_requests} since={key.daily_reset_at} /></td>
|
|
<td><QuotaBar used={key.requests_month} limit={key.monthly_requests} since={key.monthly_reset_at} /></td>
|
|
<td className="action-cell">
|
|
<button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}>✏</button>
|
|
{key.is_active ? (
|
|
<button className="btn-icon btn-icon-warn" data-tooltip="Deaktivieren" onClick={() => handleDeactivate(key.id)}>⏸</button>
|
|
) : (
|
|
<button className="btn-icon btn-icon-ok" data-tooltip="Aktivieren" onClick={() => handleActivate(key.id)}>▶</button>
|
|
)}
|
|
<button className="btn-icon btn-icon-danger" data-tooltip="Löschen" onClick={() => handleDelete(key.id, key.name)}>✕</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{editKey && (
|
|
<div className="edit-section">
|
|
<h3>Limits bearbeiten: <em>{editKey.name}</em></h3>
|
|
<form onSubmit={handleSaveEdit} className="edit-form">
|
|
<label>
|
|
Tokens/Tag (k)
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={editForm.daily_tokens}
|
|
onChange={(e) => setEditForm({ ...editForm, daily_tokens: e.target.value })}
|
|
placeholder="∞"
|
|
/>
|
|
</label>
|
|
<label>
|
|
Tokens/Monat (k)
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={editForm.monthly_tokens}
|
|
onChange={(e) => setEditForm({ ...editForm, monthly_tokens: e.target.value })}
|
|
placeholder="∞"
|
|
/>
|
|
</label>
|
|
<label>
|
|
Requests/Tag
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={editForm.daily_requests}
|
|
onChange={(e) => setEditForm({ ...editForm, daily_requests: e.target.value })}
|
|
placeholder="∞"
|
|
/>
|
|
</label>
|
|
<label>
|
|
Requests/Monat
|
|
<small>leer = unbegrenzt</small>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={editForm.monthly_requests}
|
|
onChange={(e) => setEditForm({ ...editForm, monthly_requests: e.target.value })}
|
|
placeholder="∞"
|
|
/>
|
|
</label>
|
|
<div className="edit-actions">
|
|
<button type="submit" className="btn-save">Speichern</button>
|
|
<button type="button" className="btn-cancel" onClick={() => setEditKey(null)}>Abbrechen</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>
|
|
); |