refactor: Video Chat, Voice Service, Alerts Seiten aus Core Admin entfernt
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 28s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 28s
- Kommunikation-Seiten nach Lehrer migriert - API-Routes, Health-Check, Navigation bereinigt - Screen-Flow, SBOM, Tests aktualisiert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,912 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alerts Monitoring Admin Page (migrated from website/admin/alerts)
|
|
||||||
*
|
|
||||||
* Google Alerts & Feed-Ueberwachung Dashboard
|
|
||||||
* Provides inbox management, topic configuration, rule builder, and relevance profiles
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface AlertItem {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
snippet: string
|
|
||||||
topic_name: string
|
|
||||||
relevance_score: number | null
|
|
||||||
relevance_decision: string | null
|
|
||||||
status: string
|
|
||||||
fetched_at: string
|
|
||||||
published_at: string | null
|
|
||||||
matched_rule: string | null
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Topic {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
feed_url: string
|
|
||||||
feed_type: string
|
|
||||||
is_active: boolean
|
|
||||||
fetch_interval_minutes: number
|
|
||||||
last_fetched_at: string | null
|
|
||||||
alert_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Rule {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
topic_id: string | null
|
|
||||||
conditions: Array<{
|
|
||||||
field: string
|
|
||||||
operator: string
|
|
||||||
value: string | number
|
|
||||||
}>
|
|
||||||
action_type: string
|
|
||||||
action_config: Record<string, unknown>
|
|
||||||
priority: number
|
|
||||||
is_active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Profile {
|
|
||||||
priorities: string[]
|
|
||||||
exclusions: string[]
|
|
||||||
positive_examples: Array<{ title: string; url: string }>
|
|
||||||
negative_examples: Array<{ title: string; url: string }>
|
|
||||||
policies: {
|
|
||||||
keep_threshold: number
|
|
||||||
drop_threshold: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Stats {
|
|
||||||
total_alerts: number
|
|
||||||
new_alerts: number
|
|
||||||
kept_alerts: number
|
|
||||||
review_alerts: number
|
|
||||||
dropped_alerts: number
|
|
||||||
total_topics: number
|
|
||||||
active_topics: number
|
|
||||||
total_rules: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab type
|
|
||||||
type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'
|
|
||||||
|
|
||||||
export default function AlertsPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
|
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
|
||||||
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([])
|
|
||||||
const [rules, setRules] = useState<Rule[]>([])
|
|
||||||
const [profile, setProfile] = useState<Profile | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [inboxFilter, setInboxFilter] = useState<string>('all')
|
|
||||||
|
|
||||||
const API_BASE = '/api/alerts'
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
|
|
||||||
fetch(`${API_BASE}/stats`),
|
|
||||||
fetch(`${API_BASE}/inbox?limit=50`),
|
|
||||||
fetch(`${API_BASE}/topics`),
|
|
||||||
fetch(`${API_BASE}/rules`),
|
|
||||||
fetch(`${API_BASE}/profile`),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (statsRes.ok) setStats(await statsRes.json())
|
|
||||||
if (alertsRes.ok) {
|
|
||||||
const data = await alertsRes.json()
|
|
||||||
setAlerts(data.items || [])
|
|
||||||
}
|
|
||||||
if (topicsRes.ok) {
|
|
||||||
const data = await topicsRes.json()
|
|
||||||
setTopics(data.topics || data.items || [])
|
|
||||||
}
|
|
||||||
if (rulesRes.ok) {
|
|
||||||
const data = await rulesRes.json()
|
|
||||||
setRules(data.rules || data.items || [])
|
|
||||||
}
|
|
||||||
if (profileRes.ok) setProfile(await profileRes.json())
|
|
||||||
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
|
||||||
// Set demo data
|
|
||||||
setStats({
|
|
||||||
total_alerts: 147,
|
|
||||||
new_alerts: 23,
|
|
||||||
kept_alerts: 89,
|
|
||||||
review_alerts: 12,
|
|
||||||
dropped_alerts: 23,
|
|
||||||
total_topics: 5,
|
|
||||||
active_topics: 4,
|
|
||||||
total_rules: 8,
|
|
||||||
})
|
|
||||||
setAlerts([
|
|
||||||
{
|
|
||||||
id: 'demo_1',
|
|
||||||
title: 'Neue Studie zur digitalen Bildung an Schulen',
|
|
||||||
url: 'https://example.com/artikel1',
|
|
||||||
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
|
|
||||||
topic_name: 'Digitale Bildung',
|
|
||||||
relevance_score: 0.85,
|
|
||||||
relevance_decision: 'KEEP',
|
|
||||||
status: 'new',
|
|
||||||
fetched_at: new Date().toISOString(),
|
|
||||||
published_at: null,
|
|
||||||
matched_rule: null,
|
|
||||||
tags: ['bildung', 'digital'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo_2',
|
|
||||||
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
|
|
||||||
url: 'https://example.com/artikel2',
|
|
||||||
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
|
|
||||||
topic_name: 'Inklusion',
|
|
||||||
relevance_score: 0.72,
|
|
||||||
relevance_decision: 'KEEP',
|
|
||||||
status: 'new',
|
|
||||||
fetched_at: new Date(Date.now() - 3600000).toISOString(),
|
|
||||||
published_at: null,
|
|
||||||
matched_rule: null,
|
|
||||||
tags: ['inklusion'],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setTopics([
|
|
||||||
{
|
|
||||||
id: 'topic_1',
|
|
||||||
name: 'Digitale Bildung',
|
|
||||||
feed_url: 'https://google.com/alerts/feeds/123',
|
|
||||||
feed_type: 'rss',
|
|
||||||
is_active: true,
|
|
||||||
fetch_interval_minutes: 60,
|
|
||||||
last_fetched_at: new Date().toISOString(),
|
|
||||||
alert_count: 47,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'topic_2',
|
|
||||||
name: 'Inklusion',
|
|
||||||
feed_url: 'https://google.com/alerts/feeds/456',
|
|
||||||
feed_type: 'rss',
|
|
||||||
is_active: true,
|
|
||||||
fetch_interval_minutes: 60,
|
|
||||||
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
|
|
||||||
alert_count: 32,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setRules([
|
|
||||||
{
|
|
||||||
id: 'rule_1',
|
|
||||||
name: 'Stellenanzeigen ausschliessen',
|
|
||||||
topic_id: null,
|
|
||||||
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
|
|
||||||
action_type: 'drop',
|
|
||||||
action_config: {},
|
|
||||||
priority: 10,
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setProfile({
|
|
||||||
priorities: ['Inklusion', 'digitale Bildung'],
|
|
||||||
exclusions: ['Stellenanzeigen', 'Werbung'],
|
|
||||||
positive_examples: [],
|
|
||||||
negative_examples: [],
|
|
||||||
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData()
|
|
||||||
}, [fetchData])
|
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string | null) => {
|
|
||||||
if (!dateStr) return '-'
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMins = Math.floor(diffMs / 60000)
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'gerade eben'
|
|
||||||
if (diffMins < 60) return `vor ${diffMins} Min.`
|
|
||||||
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
|
||||||
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getScoreBadge = (score: number | null) => {
|
|
||||||
if (score === null) return null
|
|
||||||
const pct = Math.round(score * 100)
|
|
||||||
let cls = 'bg-slate-100 text-slate-600'
|
|
||||||
if (pct >= 70) cls = 'bg-green-100 text-green-800'
|
|
||||||
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
|
|
||||||
else cls = 'bg-red-100 text-red-800'
|
|
||||||
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDecisionBadge = (decision: string | null) => {
|
|
||||||
if (!decision) return null
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
KEEP: 'bg-green-100 text-green-800',
|
|
||||||
REVIEW: 'bg-amber-100 text-amber-800',
|
|
||||||
DROP: 'bg-red-100 text-red-800',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
|
|
||||||
{decision}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredAlerts = alerts.filter((alert) => {
|
|
||||||
if (inboxFilter === 'all') return true
|
|
||||||
if (inboxFilter === 'new') return alert.status === 'new'
|
|
||||||
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
|
|
||||||
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabs: { id: TabId; label: string; badge?: number }[] = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
|
|
||||||
{ id: 'topics', label: 'Topics' },
|
|
||||||
{ id: 'rules', label: 'Regeln' },
|
|
||||||
{ id: 'profile', label: 'Profil' },
|
|
||||||
{ id: 'audit', label: 'Audit' },
|
|
||||||
{ id: 'documentation', label: 'Dokumentation' },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title="Alerts Monitoring"
|
|
||||||
purpose="Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung. Verwalten Sie Topics, konfigurieren Sie Filterregeln und nutzen Sie LLM-basiertes Scoring fuer automatische Kategorisierung."
|
|
||||||
audience={['Marketing', 'Admins', 'DSB']}
|
|
||||||
architecture={{
|
|
||||||
services: ['backend (FastAPI)', 'APScheduler', 'LLM Gateway'],
|
|
||||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
|
||||||
}}
|
|
||||||
relatedPages={[
|
|
||||||
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail-Konten verwalten' },
|
|
||||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
|
||||||
]}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stats Overview */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Alerts gesamt</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Neue Alerts</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Relevant</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Zur Pruefung</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="bg-white rounded-lg shadow mb-6">
|
|
||||||
<div className="border-b border-slate-200 px-4">
|
|
||||||
<nav className="flex gap-4 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`pb-3 pt-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-green-600 text-green-600'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{tab.badge !== undefined && tab.badge > 0 && (
|
|
||||||
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
|
|
||||||
{tab.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Dashboard Tab */}
|
|
||||||
{activeTab === 'dashboard' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-slate-50 rounded-xl p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{topics.slice(0, 5).map((topic) => (
|
|
||||||
<div key={topic.id} className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-slate-900">{topic.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
|
|
||||||
{topic.is_active ? 'Aktiv' : 'Pausiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{topics.length === 0 && (
|
|
||||||
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-50 rounded-xl p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{alerts.slice(0, 5).map((alert) => (
|
|
||||||
<div key={alert.id} className="p-3 bg-white rounded-lg border border-slate-200">
|
|
||||||
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-xs text-slate-500">{alert.topic_name}</span>
|
|
||||||
{getScoreBadge(alert.relevance_score)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{alerts.length === 0 && (
|
|
||||||
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inbox Tab */}
|
|
||||||
{activeTab === 'inbox' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{['all', 'new', 'keep', 'review'].map((filter) => (
|
|
||||||
<button
|
|
||||||
key={filter}
|
|
||||||
onClick={() => setInboxFilter(filter)}
|
|
||||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
|
||||||
inboxFilter === filter
|
|
||||||
? 'bg-green-600 text-white'
|
|
||||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{filter === 'all' && 'Alle'}
|
|
||||||
{filter === 'new' && 'Neu'}
|
|
||||||
{filter === 'keep' && 'Relevant'}
|
|
||||||
{filter === 'review' && 'Pruefung'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts Table */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{filteredAlerts.map((alert) => (
|
|
||||||
<tr key={alert.id} className="hover:bg-slate-50">
|
|
||||||
<td className="p-4">
|
|
||||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-green-600">
|
|
||||||
{alert.title}
|
|
||||||
</a>
|
|
||||||
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
|
|
||||||
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
|
|
||||||
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
|
|
||||||
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{filteredAlerts.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
|
||||||
Keine Alerts gefunden
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Topics Tab */}
|
|
||||||
{activeTab === 'topics' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
|
||||||
+ Topic hinzufuegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{topics.map((topic) => (
|
|
||||||
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
|
|
||||||
{topic.is_active ? 'Aktiv' : 'Pausiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
|
|
||||||
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
|
|
||||||
<span className="text-slate-500"> Alerts</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
{formatTimeAgo(topic.last_fetched_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{topics.length === 0 && (
|
|
||||||
<div className="col-span-full text-center py-8 text-slate-500">
|
|
||||||
Keine Topics konfiguriert
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rules Tab */}
|
|
||||||
{activeTab === 'rules' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
|
||||||
+ Regel erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
|
|
||||||
{rules.map((rule) => (
|
|
||||||
<div key={rule.id} className="p-4 flex items-center gap-4">
|
|
||||||
<div className="text-slate-400 cursor-grab">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-slate-900">{rule.name}</div>
|
|
||||||
<div className="text-sm text-slate-500">
|
|
||||||
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} "{rule.conditions[0]?.value}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
|
|
||||||
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
|
|
||||||
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
|
|
||||||
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
|
|
||||||
'bg-purple-100 text-purple-800'
|
|
||||||
}`}>
|
|
||||||
{rule.action_type}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
|
|
||||||
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all shadow ${
|
|
||||||
rule.is_active ? 'left-6' : 'left-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-slate-500">
|
|
||||||
Keine Regeln konfiguriert
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profile Tab */}
|
|
||||||
{activeTab === 'profile' && (
|
|
||||||
<div className="max-w-2xl space-y-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Prioritaeten (wichtige Themen)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={profile?.priorities?.join('\n') || ''}
|
|
||||||
placeholder="Ein Thema pro Zeile..."
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Ausschluesse (unerwuenschte Themen)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={profile?.exclusions?.join('\n') || ''}
|
|
||||||
placeholder="Ein Thema pro Zeile..."
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Schwellenwert KEEP
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
defaultValue={profile?.policies?.keep_threshold || 0.7}
|
|
||||||
>
|
|
||||||
<option value={0.8}>80% (sehr streng)</option>
|
|
||||||
<option value={0.7}>70% (empfohlen)</option>
|
|
||||||
<option value={0.6}>60% (weniger streng)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Schwellenwert DROP
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
defaultValue={profile?.policies?.drop_threshold || 0.3}
|
|
||||||
>
|
|
||||||
<option value={0.4}>40% (strenger)</option>
|
|
||||||
<option value={0.3}>30% (empfohlen)</option>
|
|
||||||
<option value={0.2}>20% (lockerer)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
|
||||||
Profil speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audit Tab */}
|
|
||||||
{activeTab === 'audit' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{/* Database Info */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
|
||||||
</svg>
|
|
||||||
Datenbank
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Tabellen</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Indizes</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-slate-600">Backups</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Security */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
API Sicherheit
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Authentifizierung</span>
|
|
||||||
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Rate Limiting</span>
|
|
||||||
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-slate-600">Input Validation</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logging */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Logging & Monitoring
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Structured Logging</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Metriken</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-slate-600">Health Checks</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Privacy Notes */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
Alle Daten werden in Deutschland gespeichert (PostgreSQL)
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
DSGVO-konforme Datenverarbeitung
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documentation Tab */}
|
|
||||||
{activeTab === 'documentation' && (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
|
|
||||||
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audit Box */}
|
|
||||||
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
|
|
||||||
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ziel des Systems */}
|
|
||||||
<h2>Ziel des Alert-Systems</h2>
|
|
||||||
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
|
|
||||||
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
|
|
||||||
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
|
|
||||||
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
|
|
||||||
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
|
|
||||||
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Architecture Diagram */}
|
|
||||||
<h2>Systemarchitektur</h2>
|
|
||||||
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
|
|
||||||
<pre className="text-green-400 text-xs">{`
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ BreakPilot Alerts Frontend │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
|
|
||||||
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
|
|
||||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
|
|
||||||
└───────────────────────────────┬─────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Ingestion Layer │
|
|
||||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
||||||
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
|
|
||||||
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
|
|
||||||
│ └───────────────────┼───────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Deduplication (URL-Hash + SimHash) │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Processing Layer │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Rule Engine │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ LLM Relevance Scorer │ │
|
|
||||||
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Action Layer │
|
|
||||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
||||||
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
|
|
||||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Storage Layer │
|
|
||||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
||||||
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
|
|
||||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘`}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Endpoints */}
|
|
||||||
<h2>API Endpoints</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/inbox</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Inbox Items abrufen</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/ingest</td><td className="px-4 py-2">POST</td><td className="px-4 py-2 text-slate-600">Manuell Alert importieren</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/topics</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Topics verwalten</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/rules</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Regeln verwalten</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/profile</td><td className="px-4 py-2">GET/PUT</td><td className="px-4 py-2 text-slate-600">Profil abrufen/aktualisieren</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/stats</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Statistiken abrufen</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rule Engine */}
|
|
||||||
<h2>Rule Engine - Operatoren</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">contains</td><td className="px-4 py-2">Text enthaelt</td><td className="px-4 py-2 text-slate-600">title contains "Inklusion"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">not_contains</td><td className="px-4 py-2">Text enthaelt nicht</td><td className="px-4 py-2 text-slate-600">title not_contains "Werbung"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">equals</td><td className="px-4 py-2">Exakte Uebereinstimmung</td><td className="px-4 py-2 text-slate-600">status equals "new"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">regex</td><td className="px-4 py-2">Regulaerer Ausdruck</td><td className="px-4 py-2 text-slate-600">title regex "\d{4}"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">gt / lt</td><td className="px-4 py-2">Groesser/Kleiner</td><td className="px-4 py-2 text-slate-600">relevance_score gt 0.8</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scoring */}
|
|
||||||
<h2>LLM Relevanz-Scoring</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Bedeutung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
|
|
||||||
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
|
|
||||||
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact */}
|
|
||||||
<h2>Kontakt & Support</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2">Technischer Support</td><td className="px-4 py-2">support@breakpilot.de</td></tr>
|
|
||||||
<tr><td className="px-4 py-2">Datenschutzbeauftragter</td><td className="px-4 py-2">dsb@breakpilot.de</td></tr>
|
|
||||||
<tr><td className="px-4 py-2">Dokumentation</td><td className="px-4 py-2">docs.breakpilot.de</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
|
|
||||||
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -121,7 +121,6 @@ export default function MailAdminPage() {
|
|||||||
}}
|
}}
|
||||||
relatedPages={[
|
relatedPages={[
|
||||||
{ name: 'Mail Wizard', href: '/communication/mail/wizard', description: 'Interaktives Setup und Testing' },
|
{ name: 'Mail Wizard', href: '/communication/mail/wizard', description: 'Interaktives Setup und Testing' },
|
||||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
|
||||||
]}
|
]}
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
defaultCollapsed={false}
|
defaultCollapsed={false}
|
||||||
|
|||||||
@@ -1,594 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Voice Service Admin Page (migrated from website/admin/voice)
|
|
||||||
*
|
|
||||||
* Displays:
|
|
||||||
* - Voice-First Architecture Overview
|
|
||||||
* - Developer Guide Content
|
|
||||||
* - Live Voice Demo (embedded from studio-v2)
|
|
||||||
* - Task State Machine Documentation
|
|
||||||
* - DSGVO Compliance Information
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
|
|
||||||
type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
|
|
||||||
|
|
||||||
// Task State Machine data
|
|
||||||
const TASK_STATES = [
|
|
||||||
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
|
|
||||||
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
|
|
||||||
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
|
|
||||||
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
|
|
||||||
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
|
|
||||||
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
|
|
||||||
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
|
|
||||||
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
|
|
||||||
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Intent Types (22 types organized by group)
|
|
||||||
const INTENT_GROUPS = [
|
|
||||||
{
|
|
||||||
group: 'Notizen',
|
|
||||||
color: 'bg-blue-50 border-blue-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
|
|
||||||
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
|
|
||||||
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
|
|
||||||
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
|
|
||||||
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Content-Generierung',
|
|
||||||
color: 'bg-green-50 border-green-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
|
|
||||||
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
|
|
||||||
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
|
|
||||||
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Kommunikation',
|
|
||||||
color: 'bg-yellow-50 border-yellow-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
|
|
||||||
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Canvas-Editor',
|
|
||||||
color: 'bg-purple-50 border-purple-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
|
|
||||||
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
|
|
||||||
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
|
|
||||||
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'RAG & Korrektur',
|
|
||||||
color: 'bg-pink-50 border-pink-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
|
|
||||||
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
|
|
||||||
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Follow-up (TaskOrchestrator)',
|
|
||||||
color: 'bg-teal-50 border-teal-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
|
|
||||||
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
|
|
||||||
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// DSGVO Data Categories
|
|
||||||
const DSGVO_CATEGORIES = [
|
|
||||||
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' },
|
|
||||||
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' },
|
|
||||||
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' },
|
|
||||||
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' },
|
|
||||||
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' },
|
|
||||||
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// API Endpoints
|
|
||||||
const API_ENDPOINTS = [
|
|
||||||
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
|
|
||||||
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
|
|
||||||
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
|
|
||||||
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
|
|
||||||
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
|
|
||||||
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
|
|
||||||
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
|
|
||||||
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
|
|
||||||
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
|
|
||||||
{ method: 'GET', path: '/health', description: 'Health Check' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function VoiceMatrixPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
|
||||||
const [demoLoaded, setDemoLoaded] = useState(false)
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
|
|
||||||
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
|
|
||||||
{ id: 'tasks', name: 'Task States', icon: '📋' },
|
|
||||||
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
|
|
||||||
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
|
|
||||||
{ id: 'api', name: 'API', icon: '🔌' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title="Voice Service"
|
|
||||||
purpose="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator. Konfigurieren und testen Sie den Voice-Service fuer Lehrer-Interaktionen per Sprache."
|
|
||||||
audience={['Entwickler', 'Admins']}
|
|
||||||
architecture={{
|
|
||||||
services: ['voice-service (Python, Port 8091)', 'studio-v2 (Next.js)', 'valkey (Cache)'],
|
|
||||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
|
||||||
}}
|
|
||||||
relatedPages={[
|
|
||||||
{ name: 'Matrix & Jitsi', href: '/communication/matrix', description: 'Kommunikation Monitoring' },
|
|
||||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider vergleichen' },
|
|
||||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice-Service' },
|
|
||||||
]}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="mb-6 flex flex-wrap gap-3">
|
|
||||||
<a
|
|
||||||
href="https://macmini:3001/voice-test"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
|
||||||
</svg>
|
|
||||||
Voice Test (Studio)
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://macmini:8091/health"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Health Check
|
|
||||||
</a>
|
|
||||||
<Link
|
|
||||||
href="/development/docs"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Developer Docs
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Overview */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-teal-600">8091</div>
|
|
||||||
<div className="text-sm text-slate-500">Port</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-blue-600">22</div>
|
|
||||||
<div className="text-sm text-slate-500">Task Types</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-purple-600">9</div>
|
|
||||||
<div className="text-sm text-slate-500">Task States</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-green-600">24kHz</div>
|
|
||||||
<div className="text-sm text-slate-500">Audio Rate</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-orange-600">80ms</div>
|
|
||||||
<div className="text-sm text-slate-500">Frame Size</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-red-600">0</div>
|
|
||||||
<div className="text-sm text-slate-500">Audio Persist</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="bg-white rounded-lg shadow mb-6">
|
|
||||||
<div className="border-b border-slate-200 px-4">
|
|
||||||
<div className="flex gap-1 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id as TabType)}
|
|
||||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-teal-600 text-teal-600'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="mr-2">{tab.icon}</span>
|
|
||||||
{tab.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Overview Tab */}
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
|
|
||||||
|
|
||||||
{/* Architecture Diagram */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
|
||||||
<pre className="text-slate-700">{`
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ LEHRERGERAET (PWA / App) │
|
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
|
|
||||||
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
└───────────────────────────┬──────────────────────────────────────┘
|
|
||||||
│ WebSocket (wss://)
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ VOICE SERVICE (Port 8091) │
|
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
└───────────────────────────┬──────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────────┼──────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
|
|
||||||
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
|
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
||||||
`}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Technology Stack */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
|
|
||||||
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
|
|
||||||
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
|
|
||||||
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
|
|
||||||
<p className="text-sm text-green-700">TaskOrchestrator</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
|
|
||||||
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
|
|
||||||
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
|
|
||||||
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
|
|
||||||
<p className="text-xs text-purple-500">Lizenz: MIT</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Files */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
|
|
||||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
|
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Demo Tab */}
|
|
||||||
{activeTab === 'demo' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
|
|
||||||
<a
|
|
||||||
href="https://macmini:3001/voice-test"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
In neuem Tab oeffnen
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
|
|
||||||
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
|
|
||||||
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Embedded Demo */}
|
|
||||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
|
|
||||||
{!demoLoaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setDemoLoaded(true)}
|
|
||||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Voice Demo laden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{demoLoaded && (
|
|
||||||
<iframe
|
|
||||||
src="https://macmini:3001/voice-test?embed=true"
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Voice Demo"
|
|
||||||
allow="microphone"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task States Tab */}
|
|
||||||
{activeTab === 'tasks' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
|
|
||||||
|
|
||||||
{/* State Diagram */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
|
||||||
<pre className="text-slate-700">{`
|
|
||||||
DRAFT → QUEUED → RUNNING → READY
|
|
||||||
│
|
|
||||||
┌───────────┴───────────┐
|
|
||||||
│ │
|
|
||||||
APPROVED REJECTED
|
|
||||||
│ │
|
|
||||||
COMPLETED DRAFT (revision)
|
|
||||||
|
|
||||||
Any State → EXPIRED (TTL)
|
|
||||||
Any State → PAUSED (User Interrupt)
|
|
||||||
`}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* States Table */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{TASK_STATES.map((state) => (
|
|
||||||
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
|
|
||||||
<div className="font-semibold text-lg">{state.state}</div>
|
|
||||||
<p className="text-sm mt-1">{state.description}</p>
|
|
||||||
{state.next.length > 0 && (
|
|
||||||
<div className="mt-2 text-xs">
|
|
||||||
<span className="opacity-75">Naechste:</span>{' '}
|
|
||||||
{state.next.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Intents Tab */}
|
|
||||||
{activeTab === 'intents' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
|
|
||||||
|
|
||||||
{INTENT_GROUPS.map((group) => (
|
|
||||||
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{group.intents.map((intent) => (
|
|
||||||
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">
|
|
||||||
{intent.type}
|
|
||||||
</code>
|
|
||||||
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-slate-500 italic">
|
|
||||||
Beispiel: "{intent.example}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DSGVO Tab */}
|
|
||||||
{activeTab === 'dsgvo' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
|
||||||
|
|
||||||
{/* Key Principles */}
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
|
||||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
|
||||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
|
||||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
|
||||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
|
||||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Categories Table */}
|
|
||||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{DSGVO_CATEGORIES.map((cat) => (
|
|
||||||
<tr key={cat.category}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="mr-2">{cat.icon}</span>
|
|
||||||
<span className="font-medium">{cat.category}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
|
|
||||||
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{cat.risk.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audit Log Info */}
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
|
||||||
<li>ref_id (truncated)</li>
|
|
||||||
<li>content_type</li>
|
|
||||||
<li>size_bytes</li>
|
|
||||||
<li>ttl_hours</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-red-600 font-medium">Verboten:</span>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
|
||||||
<li>user_name</li>
|
|
||||||
<li>content / transcript</li>
|
|
||||||
<li>email</li>
|
|
||||||
<li>student_name</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* API Tab */}
|
|
||||||
{activeTab === 'api' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
|
||||||
|
|
||||||
{/* REST Endpoints */}
|
|
||||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{API_ENDPOINTS.map((ep, idx) => (
|
|
||||||
<tr key={idx}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
|
|
||||||
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
|
||||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
|
|
||||||
'bg-purple-100 text-purple-700'
|
|
||||||
}`}>
|
|
||||||
{ep.method}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* WebSocket Protocol */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
|
||||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
|
||||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Example curl commands */}
|
|
||||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
|
||||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
|
||||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST https://macmini:8091/api/v1/sessions \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"namespace_id": "ns-12345678abcdef12345678abcdef12",
|
|
||||||
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
|
|
||||||
"device_type": "pwa"
|
|
||||||
}'`}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,635 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video & Chat Admin Page
|
|
||||||
*
|
|
||||||
* Matrix & Jitsi Monitoring Dashboard
|
|
||||||
* Provides system statistics, active calls, user metrics, and service health
|
|
||||||
* Migrated from website/app/admin/communication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
import { getModuleByHref } from '@/lib/navigation'
|
|
||||||
|
|
||||||
interface MatrixStats {
|
|
||||||
total_users: number
|
|
||||||
active_users: number
|
|
||||||
total_rooms: number
|
|
||||||
active_rooms: number
|
|
||||||
messages_today: number
|
|
||||||
messages_this_week: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JitsiStats {
|
|
||||||
active_meetings: number
|
|
||||||
total_participants: number
|
|
||||||
meetings_today: number
|
|
||||||
average_duration_minutes: number
|
|
||||||
peak_concurrent_users: number
|
|
||||||
total_minutes_today: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrafficStats {
|
|
||||||
matrix: {
|
|
||||||
bandwidth_in_mb: number
|
|
||||||
bandwidth_out_mb: number
|
|
||||||
messages_per_minute: number
|
|
||||||
media_uploads_today: number
|
|
||||||
media_size_mb: number
|
|
||||||
}
|
|
||||||
jitsi: {
|
|
||||||
bandwidth_in_mb: number
|
|
||||||
bandwidth_out_mb: number
|
|
||||||
video_streams_active: number
|
|
||||||
audio_streams_active: number
|
|
||||||
estimated_hourly_gb: number
|
|
||||||
}
|
|
||||||
total: {
|
|
||||||
bandwidth_in_mb: number
|
|
||||||
bandwidth_out_mb: number
|
|
||||||
estimated_monthly_gb: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommunicationStats {
|
|
||||||
matrix: MatrixStats
|
|
||||||
jitsi: JitsiStats
|
|
||||||
traffic?: TrafficStats
|
|
||||||
last_updated: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveMeeting {
|
|
||||||
room_name: string
|
|
||||||
display_name: string
|
|
||||||
participants: number
|
|
||||||
started_at: string
|
|
||||||
duration_minutes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentRoom {
|
|
||||||
room_id: string
|
|
||||||
name: string
|
|
||||||
member_count: number
|
|
||||||
last_activity: string
|
|
||||||
room_type: 'class' | 'parent' | 'staff' | 'general'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoChatPage() {
|
|
||||||
const [stats, setStats] = useState<CommunicationStats | null>(null)
|
|
||||||
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
|
|
||||||
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const moduleInfo = getModuleByHref('/communication/video-chat')
|
|
||||||
|
|
||||||
// Use local API proxy
|
|
||||||
const fetchStats = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/communication/stats')
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setStats(data)
|
|
||||||
setActiveMeetings(data.active_meetings || [])
|
|
||||||
setRecentRooms(data.recent_rooms || [])
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
|
||||||
// Set mock data for display purposes when API unavailable
|
|
||||||
setStats({
|
|
||||||
matrix: {
|
|
||||||
total_users: 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'offline'
|
|
||||||
},
|
|
||||||
jitsi: {
|
|
||||||
active_meetings: 0,
|
|
||||||
total_participants: 0,
|
|
||||||
meetings_today: 0,
|
|
||||||
average_duration_minutes: 0,
|
|
||||||
peak_concurrent_users: 0,
|
|
||||||
total_minutes_today: 0,
|
|
||||||
status: 'offline'
|
|
||||||
},
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStats()
|
|
||||||
}, [fetchStats])
|
|
||||||
|
|
||||||
// Auto-refresh every 15 seconds
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(fetchStats, 15000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [fetchStats])
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase'
|
|
||||||
switch (status) {
|
|
||||||
case 'online':
|
|
||||||
return `${baseClasses} bg-green-100 text-green-800`
|
|
||||||
case 'degraded':
|
|
||||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
|
||||||
case 'offline':
|
|
||||||
return `${baseClasses} bg-red-100 text-red-800`
|
|
||||||
default:
|
|
||||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRoomTypeBadge = (type: string) => {
|
|
||||||
const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium'
|
|
||||||
switch (type) {
|
|
||||||
case 'class':
|
|
||||||
return `${baseClasses} bg-blue-100 text-blue-700`
|
|
||||||
case 'parent':
|
|
||||||
return `${baseClasses} bg-purple-100 text-purple-700`
|
|
||||||
case 'staff':
|
|
||||||
return `${baseClasses} bg-orange-100 text-orange-700`
|
|
||||||
default:
|
|
||||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (minutes: number) => {
|
|
||||||
if (minutes < 60) return `${Math.round(minutes)} Min.`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
const mins = Math.round(minutes % 60)
|
|
||||||
return `${hours}h ${mins}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMins = Math.floor(diffMs / 60000)
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'gerade eben'
|
|
||||||
if (diffMins < 60) return `vor ${diffMins} Min.`
|
|
||||||
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
|
||||||
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traffic estimation helpers for SysEleven planning
|
|
||||||
const calculateEstimatedTraffic = (direction: 'in' | 'out'): number => {
|
|
||||||
const messages = stats?.matrix?.messages_today || 0
|
|
||||||
const callMinutes = stats?.jitsi?.total_minutes_today || 0
|
|
||||||
const participants = stats?.jitsi?.total_participants || 0
|
|
||||||
|
|
||||||
const messageTrafficMB = messages * 0.002
|
|
||||||
const videoTrafficMB = callMinutes * participants * 0.011
|
|
||||||
|
|
||||||
if (direction === 'in') {
|
|
||||||
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4
|
|
||||||
}
|
|
||||||
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateHourlyEstimate = (): number => {
|
|
||||||
const activeParticipants = stats?.jitsi?.total_participants || 0
|
|
||||||
return activeParticipants * 0.675
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateMonthlyEstimate = (): number => {
|
|
||||||
const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0
|
|
||||||
const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1
|
|
||||||
const monthlyMinutes = dailyCallMinutes * 22
|
|
||||||
return (monthlyMinutes * avgParticipants * 11) / 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
const getResourceRecommendation = (): string => {
|
|
||||||
const peakUsers = stats?.jitsi?.peak_concurrent_users || 0
|
|
||||||
const monthlyGB = calculateMonthlyEstimate()
|
|
||||||
|
|
||||||
if (monthlyGB < 10 || peakUsers < 5) {
|
|
||||||
return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)'
|
|
||||||
} else if (monthlyGB < 50 || peakUsers < 20) {
|
|
||||||
return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)'
|
|
||||||
} else if (monthlyGB < 200 || peakUsers < 50) {
|
|
||||||
return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)'
|
|
||||||
} else {
|
|
||||||
return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title={moduleInfo?.module.name || 'Video & Chat'}
|
|
||||||
purpose={moduleInfo?.module.purpose || 'Matrix & Jitsi Monitoring Dashboard'}
|
|
||||||
audience={moduleInfo?.module.audience || ['Admins', 'DevOps']}
|
|
||||||
architecture={{
|
|
||||||
services: ['synapse (Matrix)', 'jitsi-meet', 'prosody', 'jvb'],
|
|
||||||
databases: ['PostgreSQL', 'synapse-db'],
|
|
||||||
}}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="flex gap-3 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/communication/video-chat/wizard"
|
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
|
|
||||||
>
|
|
||||||
Test Wizard starten
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={fetchStats}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Status Overview */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
||||||
{/* Matrix Status Card */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
|
|
||||||
<p className="text-sm text-slate-500">E2EE Messaging</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
|
|
||||||
{stats?.matrix.status || 'offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Benutzer</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Aktiv</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Raeume</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-slate-500">Nachrichten heute</span>
|
|
||||||
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm mt-1">
|
|
||||||
<span className="text-slate-500">Diese Woche</span>
|
|
||||||
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Jitsi Status Card */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
|
|
||||||
<p className="text-sm text-slate-500">Videokonferenzen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
|
|
||||||
{stats?.jitsi.status || 'offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Live Calls</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Teilnehmer</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Calls heute</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-slate-500">Durchschnittliche Dauer</span>
|
|
||||||
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm mt-1">
|
|
||||||
<span className="text-slate-500">Peak gleichzeitig</span>
|
|
||||||
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Traffic & Bandwidth Statistics */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
|
|
||||||
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">
|
|
||||||
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">
|
|
||||||
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Stunde</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Monat</div>
|
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
|
||||||
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Matrix Traffic */}
|
|
||||||
<div className="border border-slate-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
|
||||||
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Nachrichten/Min</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Media Uploads heute</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Media Groesse</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Jitsi Traffic */}
|
|
||||||
<div className="border border-slate-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
|
||||||
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Video Streams aktiv</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Audio Streams aktiv</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Bitrate geschaetzt</span>
|
|
||||||
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SysEleven Recommendation */}
|
|
||||||
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
|
||||||
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
|
|
||||||
<div className="text-sm text-emerald-700">
|
|
||||||
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation()}</strong></p>
|
|
||||||
<p className="mt-1 text-xs text-emerald-600">
|
|
||||||
Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} |
|
|
||||||
Durchschnittliche Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
|
|
||||||
Calls heute: {stats?.jitsi?.meetings_today || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Meetings */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeMeetings.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-slate-500">
|
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<p>Keine aktiven Meetings</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
|
|
||||||
<th className="pb-3 pr-4">Meeting</th>
|
|
||||||
<th className="pb-3 pr-4">Teilnehmer</th>
|
|
||||||
<th className="pb-3 pr-4">Gestartet</th>
|
|
||||||
<th className="pb-3">Dauer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{activeMeetings.map((meeting, idx) => (
|
|
||||||
<tr key={idx} className="text-sm">
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<div className="font-medium text-slate-900">{meeting.display_name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{meeting.room_name}</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
|
||||||
{meeting.participants}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
|
|
||||||
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Chat Rooms & Usage Stats */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Raeume</h3>
|
|
||||||
|
|
||||||
{recentRooms.length === 0 ? (
|
|
||||||
<div className="text-center py-6 text-slate-500">
|
|
||||||
<p>Keine aktiven Raeume</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{recentRooms.slice(0, 5).map((room, idx) => (
|
|
||||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
|
|
||||||
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Statistics */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Call-Minuten heute</span>
|
|
||||||
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Aktive Chat-Raeume</span>
|
|
||||||
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-purple-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Aktive Nutzer</span>
|
|
||||||
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-green-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="mt-6 pt-4 border-t border-slate-100">
|
|
||||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<a
|
|
||||||
href="http://localhost:8448/_synapse/admin"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
|
||||||
>
|
|
||||||
Synapse Admin
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="http://localhost:8443"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
Jitsi Meet
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Info */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-blue-900">Service Konfiguration</h4>
|
|
||||||
<p className="text-sm text-blue-800 mt-1">
|
|
||||||
<strong>Matrix Homeserver:</strong> http://localhost:8448 (Synapse)<br />
|
|
||||||
<strong>Jitsi Meet:</strong> http://localhost:8443<br />
|
|
||||||
<strong>Auto-Refresh:</strong> Alle 15 Sekunden
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 mt-2">
|
|
||||||
<strong>Fehler:</strong> {error} - Backend nicht erreichbar
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{stats?.last_updated && (
|
|
||||||
<p className="text-xs text-blue-600 mt-2">
|
|
||||||
Letzte Aktualisierung: {new Date(stats.last_updated).toLocaleString('de-DE')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -28,10 +28,7 @@ const initialNodes: Node[] = [
|
|||||||
{ id: 'dashboard', position: { x: 400, y: 100 }, data: { label: 'Dashboard', category: 'meta' }, style: { background: '#e0f2fe', border: '2px solid #0ea5e9', borderRadius: '12px', padding: '10px 16px' } },
|
{ id: 'dashboard', position: { x: 400, y: 100 }, data: { label: 'Dashboard', category: 'meta' }, style: { background: '#e0f2fe', border: '2px solid #0ea5e9', borderRadius: '12px', padding: '10px 16px' } },
|
||||||
|
|
||||||
// Communication (Green)
|
// Communication (Green)
|
||||||
{ id: 'video-chat', position: { x: 50, y: 250 }, data: { label: 'Video & Chat', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
{ id: 'mail', position: { x: 50, y: 250 }, data: { label: 'Unified Inbox', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
||||||
{ id: 'voice-service', position: { x: 50, y: 350 }, data: { label: 'Voice Service', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'mail', position: { x: 50, y: 450 }, data: { label: 'Unified Inbox', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'alerts', position: { x: 50, y: 550 }, data: { label: 'Alerts Monitoring', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
|
|
||||||
// Infrastructure (Orange)
|
// Infrastructure (Orange)
|
||||||
{ id: 'gpu', position: { x: 300, y: 250 }, data: { label: 'GPU Infrastruktur', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
{ id: 'gpu', position: { x: 300, y: 250 }, data: { label: 'GPU Infrastruktur', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
||||||
@@ -52,15 +49,11 @@ const initialEdges: Edge[] = [
|
|||||||
{ id: 'e-role-dash', source: 'role-select', target: 'dashboard', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#0ea5e9' } },
|
{ id: 'e-role-dash', source: 'role-select', target: 'dashboard', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#0ea5e9' } },
|
||||||
|
|
||||||
// Dashboard to categories
|
// Dashboard to categories
|
||||||
{ id: 'e-dash-vc', source: 'dashboard', target: 'video-chat', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
{ id: 'e-dash-mail', source: 'dashboard', target: 'mail', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
||||||
{ id: 'e-dash-gpu', source: 'dashboard', target: 'gpu', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
{ id: 'e-dash-gpu', source: 'dashboard', target: 'gpu', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
||||||
{ id: 'e-dash-cicd', source: 'dashboard', target: 'ci-cd', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
{ id: 'e-dash-cicd', source: 'dashboard', target: 'ci-cd', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
||||||
{ id: 'e-dash-docs', source: 'dashboard', target: 'docs', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#64748b' } },
|
{ id: 'e-dash-docs', source: 'dashboard', target: 'docs', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#64748b' } },
|
||||||
|
|
||||||
// Communication internal
|
|
||||||
{ id: 'e-vc-voice', source: 'video-chat', target: 'voice-service', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
{ id: 'e-voice-mail', source: 'voice-service', target: 'mail', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
{ id: 'e-mail-alerts', source: 'mail', target: 'alerts', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
|
|
||||||
// Infrastructure internal
|
// Infrastructure internal
|
||||||
{ id: 'e-gpu-mw', source: 'gpu', target: 'middleware', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
{ id: 'e-gpu-mw', source: 'gpu', target: 'middleware', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ export default function GPUInfrastructurePage() {
|
|||||||
databases: ['PostgreSQL (Logs)'],
|
databases: ['PostgreSQL (Logs)'],
|
||||||
}}
|
}}
|
||||||
relatedPages={[
|
relatedPages={[
|
||||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
|
||||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
||||||
{ name: 'Builds', href: '/infrastructure/builds', description: 'CI/CD Pipeline' },
|
{ name: 'Builds', href: '/infrastructure/builds', description: 'CI/CD Pipeline' },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -120,11 +120,6 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
|||||||
// ===== GAME (Breakpilot Drive) =====
|
// ===== GAME (Breakpilot Drive) =====
|
||||||
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
|
||||||
// ===== VOICE SERVICE =====
|
|
||||||
{ type: 'service', name: 'Voice Service (FastAPI)', version: '1.0', category: 'voice', port: '8091', description: 'Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'PersonaPlex-7B (NVIDIA)', version: '7B', category: 'voice', port: '8998', description: 'Full-Duplex Speech-to-Speech (Produktion)', license: 'MIT/NVIDIA Open Model', sourceUrl: 'https://developer.nvidia.com' },
|
|
||||||
{ type: 'service', name: 'TaskOrchestrator', version: '1.0', category: 'voice', port: '-', description: 'Agent-Orchestrierung mit Task State Machine', license: 'Proprietary', sourceUrl: '-' },
|
|
||||||
{ type: 'service', name: 'Mimi Audio Codec', version: '1.0', category: 'voice', port: '-', description: 'Audio Streaming (24kHz, 80ms Frames)', license: 'MIT', sourceUrl: '-' },
|
|
||||||
|
|
||||||
// ===== BQAS (Quality Assurance) =====
|
// ===== BQAS (Quality Assurance) =====
|
||||||
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
|
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
|||||||
@@ -1185,9 +1185,6 @@ export default function TestDashboardPage() {
|
|||||||
const DEMO_SERVICES: ServiceTestInfo[] = [
|
const DEMO_SERVICES: ServiceTestInfo[] = [
|
||||||
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
|
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
|
||||||
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
|
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
|
||||||
{ service: 'voice-service', display_name: 'Voice Service', port: 8091, language: 'python', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 68.9, last_run: new Date().toISOString(), status: 'passed' },
|
|
||||||
{ service: 'bqas-golden', display_name: 'BQAS Golden Suite', port: 8091, language: 'python', total_tests: 97, passed_tests: 89, failed_tests: 8, skipped_tests: 0, pass_rate: 91.7, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
|
|
||||||
{ service: 'bqas-rag', display_name: 'BQAS RAG Tests', port: 8091, language: 'python', total_tests: 20, passed_tests: 18, failed_tests: 2, skipped_tests: 0, pass_rate: 90.0, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
|
|
||||||
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* Communication Admin API Route - Stats Proxy
|
|
||||||
*
|
|
||||||
* Proxies requests to Matrix/Jitsi admin endpoints via backend
|
|
||||||
* Aggregates statistics from both services
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
// Service URLs
|
|
||||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
|
||||||
const CONSENT_SERVICE_URL = process.env.CONSENT_SERVICE_URL || 'http://localhost:8081'
|
|
||||||
const MATRIX_ADMIN_URL = process.env.MATRIX_ADMIN_URL || 'http://localhost:8448'
|
|
||||||
const JITSI_URL = process.env.JITSI_URL || 'http://localhost:8443'
|
|
||||||
|
|
||||||
// Matrix Admin Token (for Synapse Admin API)
|
|
||||||
const MATRIX_ADMIN_TOKEN = process.env.MATRIX_ADMIN_TOKEN || ''
|
|
||||||
|
|
||||||
interface MatrixStats {
|
|
||||||
total_users: number
|
|
||||||
active_users: number
|
|
||||||
total_rooms: number
|
|
||||||
active_rooms: number
|
|
||||||
messages_today: number
|
|
||||||
messages_this_week: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JitsiStats {
|
|
||||||
active_meetings: number
|
|
||||||
total_participants: number
|
|
||||||
meetings_today: number
|
|
||||||
average_duration_minutes: number
|
|
||||||
peak_concurrent_users: number
|
|
||||||
total_minutes_today: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromBackend(): Promise<{
|
|
||||||
matrix: MatrixStats
|
|
||||||
jitsi: JitsiStats
|
|
||||||
active_meetings: unknown[]
|
|
||||||
recent_rooms: unknown[]
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BACKEND_URL}/api/v1/communication/admin/stats`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Backend not reachable, trying consent service:', error)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromConsentService(): Promise<{
|
|
||||||
matrix: MatrixStats
|
|
||||||
jitsi: JitsiStats
|
|
||||||
active_meetings: unknown[]
|
|
||||||
recent_rooms: unknown[]
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/communication/admin/stats`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Consent service not reachable:', error)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMatrixStats(): Promise<MatrixStats> {
|
|
||||||
try {
|
|
||||||
// Check if Matrix is reachable
|
|
||||||
const healthCheck = await fetch(`${MATRIX_ADMIN_URL}/_matrix/client/versions`, {
|
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (healthCheck.ok) {
|
|
||||||
// Try to get user count from admin API
|
|
||||||
if (MATRIX_ADMIN_TOKEN) {
|
|
||||||
try {
|
|
||||||
const usersResponse = await fetch(`${MATRIX_ADMIN_URL}/_synapse/admin/v2/users?limit=1`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${MATRIX_ADMIN_TOKEN}` },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (usersResponse.ok) {
|
|
||||||
const data = await usersResponse.json()
|
|
||||||
return {
|
|
||||||
total_users: data.total || 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'online'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Admin API not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_users: 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'degraded' // Server reachable but no admin access
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Matrix stats fetch error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_users: 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJitsiStats(): Promise<JitsiStats> {
|
|
||||||
try {
|
|
||||||
// Check if Jitsi is reachable
|
|
||||||
const healthCheck = await fetch(`${JITSI_URL}/http-bind`, {
|
|
||||||
method: 'HEAD',
|
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
active_meetings: 0,
|
|
||||||
total_participants: 0,
|
|
||||||
meetings_today: 0,
|
|
||||||
average_duration_minutes: 0,
|
|
||||||
peak_concurrent_users: 0,
|
|
||||||
total_minutes_today: 0,
|
|
||||||
status: healthCheck.ok ? 'online' : 'offline'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Jitsi stats fetch error:', error)
|
|
||||||
return {
|
|
||||||
active_meetings: 0,
|
|
||||||
total_participants: 0,
|
|
||||||
meetings_today: 0,
|
|
||||||
average_duration_minutes: 0,
|
|
||||||
peak_concurrent_users: 0,
|
|
||||||
total_minutes_today: 0,
|
|
||||||
status: 'offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Try backend first
|
|
||||||
let data = await fetchFromBackend()
|
|
||||||
|
|
||||||
// Fallback to consent service
|
|
||||||
if (!data) {
|
|
||||||
data = await fetchFromConsentService()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If both fail, try direct service checks
|
|
||||||
if (!data) {
|
|
||||||
const [matrixStats, jitsiStats] = await Promise.all([
|
|
||||||
fetchMatrixStats(),
|
|
||||||
fetchJitsiStats()
|
|
||||||
])
|
|
||||||
|
|
||||||
data = {
|
|
||||||
matrix: matrixStats,
|
|
||||||
jitsi: jitsiStats,
|
|
||||||
active_meetings: [],
|
|
||||||
recent_rooms: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
...data,
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Communication stats error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Fehler beim Abrufen der Statistiken',
|
|
||||||
matrix: { status: 'offline', total_users: 0, active_users: 0, total_rooms: 0, active_rooms: 0, messages_today: 0, messages_this_week: 0 },
|
|
||||||
jitsi: { status: 'offline', active_meetings: 0, total_participants: 0, meetings_today: 0, average_duration_minutes: 0, peak_concurrent_users: 0, total_minutes_today: 0 },
|
|
||||||
active_meetings: [],
|
|
||||||
recent_rooms: [],
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ const SERVICES: ServiceConfig[] = [
|
|||||||
// Core Services
|
// Core Services
|
||||||
{ name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' },
|
{ name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' },
|
||||||
{ name: 'Consent Service', port: 8081, endpoint: '/api/v1/health', category: 'core' },
|
{ name: 'Consent Service', port: 8081, endpoint: '/api/v1/health', category: 'core' },
|
||||||
{ name: 'Voice Service', port: 8091, endpoint: '/health', category: 'core' },
|
|
||||||
{ name: 'Klausur Service', port: 8086, endpoint: '/health', category: 'core' },
|
{ name: 'Klausur Service', port: 8086, endpoint: '/health', category: 'core' },
|
||||||
{ name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' },
|
{ name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' },
|
||||||
{ name: 'Edu Search', port: 8088, endpoint: '/health', category: 'core' },
|
{ name: 'Edu Search', port: 8088, endpoint: '/health', category: 'core' },
|
||||||
@@ -41,7 +40,6 @@ const getInternalHost = (port: number): string => {
|
|||||||
const serviceMap: Record<number, string> = {
|
const serviceMap: Record<number, string> = {
|
||||||
8000: 'backend',
|
8000: 'backend',
|
||||||
8081: 'consent-service',
|
8081: 'consent-service',
|
||||||
8091: 'voice-service',
|
|
||||||
8086: 'klausur-service',
|
8086: 'klausur-service',
|
||||||
8025: 'mailpit',
|
8025: 'mailpit',
|
||||||
8088: 'edu-search-service',
|
8088: 'edu-search-service',
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* Alerts API Proxy - Catch-all route
|
|
||||||
* Proxies all /api/alerts/* requests to backend
|
|
||||||
* Supports: inbox, topics, rules, profile, stats, etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
|
||||||
|
|
||||||
function getForwardHeaders(request: NextRequest): HeadersInit {
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward cookie for session auth
|
|
||||||
const cookie = request.headers.get('cookie')
|
|
||||||
if (cookie) {
|
|
||||||
headers['Cookie'] = cookie
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward authorization header if present
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
if (auth) {
|
|
||||||
headers['Authorization'] = auth
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const searchParams = request.nextUrl.searchParams.toString()
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,24 +36,8 @@ export const navigation: NavCategory[] = [
|
|||||||
icon: 'message-circle',
|
icon: 'message-circle',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
colorClass: 'communication',
|
colorClass: 'communication',
|
||||||
description: 'Matrix, Jitsi, E-Mail & Alerts',
|
description: 'E-Mail Management',
|
||||||
modules: [
|
modules: [
|
||||||
{
|
|
||||||
id: 'video-chat',
|
|
||||||
name: 'Video & Chat',
|
|
||||||
href: '/communication/video-chat',
|
|
||||||
description: 'Matrix & Jitsi Monitoring',
|
|
||||||
purpose: 'Dashboard fuer Matrix Synapse und Jitsi Meet. Service-Status, aktive Meetings, Traffic.',
|
|
||||||
audience: ['Admins', 'DevOps'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'matrix',
|
|
||||||
name: 'Voice Service',
|
|
||||||
href: '/communication/matrix',
|
|
||||||
description: 'PersonaPlex-7B & TaskOrchestrator',
|
|
||||||
purpose: 'Voice-First Interface Konfiguration und Architektur-Dokumentation.',
|
|
||||||
audience: ['Entwickler', 'Admins'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'mail',
|
id: 'mail',
|
||||||
name: 'Unified Inbox',
|
name: 'Unified Inbox',
|
||||||
@@ -62,14 +46,6 @@ export const navigation: NavCategory[] = [
|
|||||||
purpose: 'E-Mail-Konten verwalten und KI-Kategorisierung nutzen.',
|
purpose: 'E-Mail-Konten verwalten und KI-Kategorisierung nutzen.',
|
||||||
audience: ['Support', 'Admins'],
|
audience: ['Support', 'Admins'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'alerts',
|
|
||||||
name: 'Alerts Monitoring',
|
|
||||||
href: '/communication/alerts',
|
|
||||||
description: 'Google Alerts & Feed-Ueberwachung',
|
|
||||||
purpose: 'Google Alerts und RSS-Feeds fuer relevante Neuigkeiten ueberwachen.',
|
|
||||||
audience: ['Marketing', 'Admins'],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user