llmproxy/frontend/src/main.jsx
Oliver Hofmann 8d3f9a7661 Fix OpenAI array content, add error logging, Ollama reachability warning
- 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
2026-05-07 11:43:17 +02:00

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>&nbsp;</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>
);