fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
143
website/app/api/admin/cicd/route.ts
Normal file
143
website/app/api/admin/cicd/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Woodpecker API configuration
|
||||
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
|
||||
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
|
||||
|
||||
interface PipelineStep {
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
|
||||
exit_code: number
|
||||
}
|
||||
|
||||
interface Pipeline {
|
||||
id: number
|
||||
number: number
|
||||
status: 'pending' | 'running' | 'success' | 'failure'
|
||||
event: string
|
||||
branch: string
|
||||
commit: string
|
||||
message: string
|
||||
author: string
|
||||
created: number
|
||||
started: number
|
||||
finished: number
|
||||
steps: PipelineStep[]
|
||||
}
|
||||
|
||||
interface CICDStatusResponse {
|
||||
status: 'online' | 'offline'
|
||||
pipelines: Pipeline[]
|
||||
lastUpdate: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const repoId = searchParams.get('repo') || '1'
|
||||
const limit = parseInt(searchParams.get('limit') || '5')
|
||||
|
||||
try {
|
||||
// Fetch pipelines from Woodpecker API
|
||||
const response = await fetch(
|
||||
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines?per_page=${limit}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
// If Woodpecker is not available, return offline status
|
||||
return NextResponse.json({
|
||||
status: 'offline',
|
||||
pipelines: [],
|
||||
lastUpdate: new Date().toISOString(),
|
||||
error: 'Woodpecker API nicht erreichbar'
|
||||
} as CICDStatusResponse)
|
||||
}
|
||||
|
||||
const rawPipelines = await response.json()
|
||||
|
||||
// Transform pipelines to our format
|
||||
const pipelines: Pipeline[] = rawPipelines.map((p: any) => ({
|
||||
id: p.id,
|
||||
number: p.number,
|
||||
status: p.status,
|
||||
event: p.event,
|
||||
branch: p.branch,
|
||||
commit: p.commit?.substring(0, 7) || '',
|
||||
message: p.message || '',
|
||||
author: p.author,
|
||||
created: p.created,
|
||||
started: p.started,
|
||||
finished: p.finished,
|
||||
steps: p.workflows?.[0]?.children?.map((c: any) => ({
|
||||
name: c.name,
|
||||
state: c.state,
|
||||
exit_code: c.exit_code
|
||||
})) || []
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'online',
|
||||
pipelines,
|
||||
lastUpdate: new Date().toISOString()
|
||||
} as CICDStatusResponse)
|
||||
|
||||
} catch (error) {
|
||||
console.error('CI/CD Status API error:', error)
|
||||
return NextResponse.json({
|
||||
status: 'offline',
|
||||
pipelines: [],
|
||||
lastUpdate: new Date().toISOString(),
|
||||
error: 'Fehler beim Abrufen des CI/CD Status'
|
||||
} as CICDStatusResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger a new pipeline
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { repoId = '1', branch = 'main' } = body
|
||||
|
||||
const response = await fetch(
|
||||
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ branch }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Pipeline konnte nicht gestartet werden' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const pipeline = await response.json()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pipeline: {
|
||||
id: pipeline.id,
|
||||
number: pipeline.number,
|
||||
status: pipeline.status
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Pipeline trigger error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Fehler beim Starten der Pipeline' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
204
website/app/api/admin/communication/stats/route.ts
Normal file
204
website/app/api/admin/communication/stats/route.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Communication Admin API Route - Stats Proxy
|
||||
*
|
||||
* Proxies requests to Matrix/Jitsi admin endpoints
|
||||
* Aggregates statistics from both services
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Service URLs
|
||||
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 fetchMatrixStats(): Promise<MatrixStats> {
|
||||
try {
|
||||
// Try to get stats from consent service first
|
||||
const consentResponse = await fetch(`${CONSENT_SERVICE_URL}/api/v1/communication/admin/stats`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (consentResponse.ok) {
|
||||
const data = await consentResponse.json()
|
||||
return {
|
||||
total_users: data.matrix?.total_users || 0,
|
||||
active_users: data.matrix?.active_users || 0,
|
||||
total_rooms: data.matrix?.total_rooms || 0,
|
||||
active_rooms: data.matrix?.active_rooms || 0,
|
||||
messages_today: data.matrix?.messages_today || 0,
|
||||
messages_this_week: data.matrix?.messages_this_week || 0,
|
||||
status: 'online'
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try direct Matrix Admin API
|
||||
if (MATRIX_ADMIN_TOKEN) {
|
||||
const response = await fetch(`${MATRIX_ADMIN_URL}/_synapse/admin/v1/statistics/users/media`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MATRIX_ADMIN_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
total_users: 0,
|
||||
active_users: 0,
|
||||
total_rooms: 0,
|
||||
active_rooms: 0,
|
||||
messages_today: 0,
|
||||
messages_this_week: 0,
|
||||
status: 'online'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Matrix is at least reachable
|
||||
const healthCheck = await fetch(`${MATRIX_ADMIN_URL}/_matrix/client/versions`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
|
||||
return {
|
||||
total_users: 0,
|
||||
active_users: 0,
|
||||
total_rooms: 0,
|
||||
active_rooms: 0,
|
||||
messages_today: 0,
|
||||
messages_this_week: 0,
|
||||
status: healthCheck.ok ? 'degraded' : 'offline'
|
||||
}
|
||||
} 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 {
|
||||
// Try to get stats from consent service
|
||||
const consentResponse = await fetch(`${CONSENT_SERVICE_URL}/api/v1/communication/admin/stats`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (consentResponse.ok) {
|
||||
const data = await consentResponse.json()
|
||||
return {
|
||||
active_meetings: data.jitsi?.active_meetings || 0,
|
||||
total_participants: data.jitsi?.total_participants || 0,
|
||||
meetings_today: data.jitsi?.meetings_today || 0,
|
||||
average_duration_minutes: data.jitsi?.average_duration_minutes || 0,
|
||||
peak_concurrent_users: data.jitsi?.peak_concurrent_users || 0,
|
||||
total_minutes_today: data.jitsi?.total_minutes_today || 0,
|
||||
status: 'online'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Jitsi is at least 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 ? 'degraded' : '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 {
|
||||
// Fetch stats from both services in parallel
|
||||
const [matrixStats, jitsiStats] = await Promise.all([
|
||||
fetchMatrixStats(),
|
||||
fetchJitsiStats()
|
||||
])
|
||||
|
||||
// Try to get active meetings and rooms from consent service
|
||||
let activeMeetings: unknown[] = []
|
||||
let recentRooms: unknown[] = []
|
||||
|
||||
try {
|
||||
const consentResponse = await fetch(`${CONSENT_SERVICE_URL}/api/v1/communication/admin/stats`)
|
||||
if (consentResponse.ok) {
|
||||
const data = await consentResponse.json()
|
||||
activeMeetings = data.active_meetings || []
|
||||
recentRooms = data.recent_rooms || []
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, use empty arrays
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
matrix: matrixStats,
|
||||
jitsi: jitsiStats,
|
||||
active_meetings: activeMeetings,
|
||||
recent_rooms: recentRooms,
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Consent Admin API Route - Document Versions Proxy
|
||||
*
|
||||
* Proxies requests to consent service version endpoints
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const CONSENT_SERVICE_URL = process.env.CONSENT_SERVICE_URL || 'http://localhost:8081'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
|
||||
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/documents/${id}/versions`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeader ? { 'Authorization': authHeader } : {})
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Consent Service Error: ${response.status}`, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Versions proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Consent Service fehlgeschlagen', versions: [] },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/documents/${id}/versions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeader ? { 'Authorization': authHeader } : {})
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Consent Service Error: ${response.status}`, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create version proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Consent Service fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
77
website/app/api/admin/consent/documents/route.ts
Normal file
77
website/app/api/admin/consent/documents/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Consent Admin API Route - Documents Proxy
|
||||
*
|
||||
* Proxies requests to consent service documents endpoints
|
||||
* Avoids CORS issues when browser accesses different ports
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Service URL - use docker network hostname in container, localhost for local dev
|
||||
const CONSENT_SERVICE_URL = process.env.CONSENT_SERVICE_URL || 'http://localhost:8081'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
|
||||
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/documents`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeader ? { 'Authorization': authHeader } : {})
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Consent service documents error:', response.status, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: `Consent Service Error: ${response.status}`, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Documents proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Consent Service fehlgeschlagen', documents: [] },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeader ? { 'Authorization': authHeader } : {})
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Consent Service Error: ${response.status}`, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create document proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Consent Service fehlgeschlagen' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
354
website/app/api/admin/edu-search/route.ts
Normal file
354
website/app/api/admin/edu-search/route.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* API Proxy for edu-search-service Admin Operations
|
||||
*
|
||||
* Provides secure server-side access to edu-search-service API
|
||||
* with API key authentication handled server-side
|
||||
*
|
||||
* Seeds are fetched from Python Backend (port 8000)
|
||||
* Other operations (stats, crawl) go to Go edu-search-service (port 8084)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const EDU_SEARCH_URL = process.env.EDU_SEARCH_URL || 'http://localhost:8084'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
const EDU_SEARCH_API_KEY = process.env.EDU_SEARCH_API_KEY || ''
|
||||
|
||||
// GET: Fetch seeds, stats, or health
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
// Seeds come from Python Backend - fetch all pages for admin view
|
||||
if (action === 'seeds') {
|
||||
const category = searchParams.get('category')
|
||||
const pageSize = 200 // Max allowed by backend
|
||||
|
||||
// Fetch first page to get total count
|
||||
let seedsUrl = `${BACKEND_URL}/v1/edu-search/seeds?page=1&page_size=${pageSize}`
|
||||
if (category) {
|
||||
seedsUrl += `&category=${category}`
|
||||
}
|
||||
|
||||
const firstResponse = await fetch(seedsUrl, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!firstResponse.ok) {
|
||||
throw new Error(`Backend responded with ${firstResponse.status}`)
|
||||
}
|
||||
|
||||
const firstData = await firstResponse.json()
|
||||
let allSeeds = firstData.seeds || []
|
||||
const total = firstData.total || allSeeds.length
|
||||
|
||||
// Fetch additional pages if needed
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
let nextUrl = `${BACKEND_URL}/v1/edu-search/seeds?page=${page}&page_size=${pageSize}`
|
||||
if (category) {
|
||||
nextUrl += `&category=${category}`
|
||||
}
|
||||
|
||||
const nextResponse = await fetch(nextUrl, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (nextResponse.ok) {
|
||||
const nextData = await nextResponse.json()
|
||||
allSeeds = allSeeds.concat(nextData.seeds || [])
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
seeds: allSeeds,
|
||||
total: total,
|
||||
page: 1,
|
||||
page_size: total,
|
||||
})
|
||||
}
|
||||
|
||||
// Categories come from Python Backend
|
||||
if (action === 'categories') {
|
||||
const categoriesResponse = await fetch(`${BACKEND_URL}/v1/edu-search/categories`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!categoriesResponse.ok) {
|
||||
throw new Error(`Backend responded with ${categoriesResponse.status}`)
|
||||
}
|
||||
|
||||
const data = await categoriesResponse.json()
|
||||
// Backend returns array directly, wrap it for frontend compatibility
|
||||
const categories = Array.isArray(data) ? data : data.categories || []
|
||||
return NextResponse.json({ categories })
|
||||
}
|
||||
|
||||
// Stats come from Python Backend
|
||||
if (action === 'stats') {
|
||||
const statsResponse = await fetch(`${BACKEND_URL}/v1/edu-search/stats`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(`Backend responded with ${statsResponse.status}`)
|
||||
}
|
||||
|
||||
const data = await statsResponse.json()
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
// Other actions go to Go edu-search-service
|
||||
let endpoint = '/v1/health'
|
||||
|
||||
switch (action) {
|
||||
case 'stats':
|
||||
endpoint = '/v1/admin/stats'
|
||||
break
|
||||
case 'health':
|
||||
endpoint = '/v1/health'
|
||||
break
|
||||
default:
|
||||
endpoint = '/v1/health'
|
||||
}
|
||||
|
||||
const response = await fetch(`${EDU_SEARCH_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${EDU_SEARCH_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Return mock data for development when service is not running
|
||||
if (action === 'stats') {
|
||||
return NextResponse.json({
|
||||
totalDocuments: 15234,
|
||||
totalSeeds: 127,
|
||||
lastCrawlTime: new Date().toISOString(),
|
||||
crawlStatus: 'idle',
|
||||
documentsPerCategory: {
|
||||
federal: 4521,
|
||||
states: 5234,
|
||||
science: 1234,
|
||||
universities: 2345,
|
||||
portals: 1900,
|
||||
},
|
||||
documentsPerDocType: {
|
||||
Lehrplan: 2345,
|
||||
Arbeitsblatt: 4567,
|
||||
Unterrichtsentwurf: 1234,
|
||||
Erlass_Verordnung: 890,
|
||||
Pruefung_Abitur: 567,
|
||||
Studie_Bericht: 345,
|
||||
Sonstiges: 5286,
|
||||
},
|
||||
avgTrustScore: 0.72,
|
||||
})
|
||||
}
|
||||
|
||||
// Seeds are now handled by Python Backend above, this case shouldn't be reached
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'mock',
|
||||
message: 'edu-search-service not available, using mock data',
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('edu-search API error:', error)
|
||||
|
||||
// Return mock data on error
|
||||
if (action === 'health') {
|
||||
return NextResponse.json({
|
||||
status: 'mock',
|
||||
opensearch: 'unavailable',
|
||||
service: 'edu-search-service',
|
||||
version: '0.1.0',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to edu-search-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create seed (via Python Backend), or start crawl (via Go service)
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Seed creation goes to Python Backend
|
||||
if (action === 'seed') {
|
||||
const response = await fetch(`${BACKEND_URL}/v1/edu-search/seeds`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
{ error: errData.detail || `Backend responded with ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
// Crawl start - calls both Legal Crawler (Python) and Go edu-search-service
|
||||
if (action === 'crawl') {
|
||||
const results: { legal?: unknown; eduSearch?: unknown } = {}
|
||||
|
||||
// 1. Start Legal Crawler (Python Backend) - crawls Schulgesetze
|
||||
try {
|
||||
const legalResponse = await fetch(`${BACKEND_URL}/v1/legal-crawler/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (legalResponse.ok) {
|
||||
results.legal = await legalResponse.json()
|
||||
} else {
|
||||
results.legal = { status: 'error', message: `Legal crawler returned ${legalResponse.status}` }
|
||||
}
|
||||
} catch (err) {
|
||||
results.legal = { status: 'error', message: 'Legal crawler not available' }
|
||||
}
|
||||
|
||||
// 2. Start Go edu-search-service crawler (if available)
|
||||
try {
|
||||
const eduResponse = await fetch(`${EDU_SEARCH_URL}/v1/admin/crawl/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${EDU_SEARCH_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (eduResponse.ok) {
|
||||
results.eduSearch = await eduResponse.json()
|
||||
} else {
|
||||
results.eduSearch = { status: 'skipped', message: 'Go edu-search-service not available' }
|
||||
}
|
||||
} catch (err) {
|
||||
results.eduSearch = { status: 'skipped', message: 'Go edu-search-service not running' }
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'started',
|
||||
message: 'Crawl gestartet',
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
// Legal Crawler Status
|
||||
if (action === 'legal-crawler-status') {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/v1/legal-crawler/status`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall through to error response
|
||||
}
|
||||
return NextResponse.json({ status: 'error', message: 'Legal crawler not available' })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('edu-search API POST error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process request' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update seed (via Python Backend)
|
||||
export async function PUT(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const seedId = searchParams.get('id')
|
||||
|
||||
if (!seedId) {
|
||||
return NextResponse.json({ error: 'Seed ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/v1/edu-search/seeds/${seedId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
{ error: errData.detail || `Backend responded with ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('edu-search API PUT error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update seed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove seed (via Python Backend)
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const seedId = searchParams.get('id')
|
||||
|
||||
if (!seedId) {
|
||||
return NextResponse.json({ error: 'Seed ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/v1/edu-search/seeds/${seedId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
{ error: errData.detail || `Backend responded with ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ deleted: true, id: seedId })
|
||||
} catch (error) {
|
||||
console.error('edu-search API DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete seed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
82
website/app/api/admin/gpu/route.ts
Normal file
82
website/app/api/admin/gpu/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* GPU Admin API Route - Secure Proxy
|
||||
*
|
||||
* Der vast.ai API-Key wird serverseitig aus der Umgebungsvariable geladen
|
||||
* und niemals an den Client gesendet.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// API Key für Backend-Authentifizierung (serverseitig, niemals im Client!)
|
||||
// CONTROL_API_KEY ist der Backend-API-Key, VAST_API_KEY ist für vast.ai direkt
|
||||
const BACKEND_API_KEY = process.env.CONTROL_API_KEY || process.env.VAST_API_KEY
|
||||
|
||||
// Backend URL
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!BACKEND_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'CONTROL_API_KEY nicht konfiguriert' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/infra/vast/status`, {
|
||||
headers: {
|
||||
'X-API-Key': BACKEND_API_KEY,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('GPU Status fetch error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar', status: 'error' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!BACKEND_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: 'CONTROL_API_KEY nicht konfiguriert' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const action = body.action // 'on' or 'off'
|
||||
|
||||
if (!['on', 'off'].includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ungültige Aktion' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const endpoint = action === 'on' ? '/infra/vast/power/on' : '/infra/vast/power/off'
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': BACKEND_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('GPU Power action error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
287
website/app/api/admin/pca/route.ts
Normal file
287
website/app/api/admin/pca/route.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* API Proxy for PCA Platform (Heuristic Service)
|
||||
*
|
||||
* Provides secure server-side access to the PCA Heuristic Service
|
||||
* for bot detection, session monitoring, and configuration management
|
||||
*
|
||||
* Heuristic Service runs on port 8085
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const PCA_SERVICE_URL = process.env.PCA_SERVICE_URL || 'http://localhost:8085'
|
||||
|
||||
// Helper to make fetch with timeout and error handling
|
||||
async function safeFetch(url: string, options?: RequestInit): Promise<Response | null> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
return response
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// GET: Fetch health, sessions, config, or stats
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
// Health check
|
||||
if (action === 'health') {
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/health`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json({
|
||||
status: 'offline',
|
||||
message: 'PCA Heuristic Service not available',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
...data,
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'healthy' })
|
||||
}
|
||||
}
|
||||
|
||||
// Get client config
|
||||
if (action === 'config') {
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/pca/v1/config`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response && response.ok) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Return default config if service unavailable or error
|
||||
return NextResponse.json({
|
||||
tick: { endpoint: '/pca/v1/tick', interval_ms: 5000 },
|
||||
thresholds: { score_pass: 0.7, score_challenge: 0.4 },
|
||||
weights: {
|
||||
dwell_ratio: 0.30,
|
||||
scroll_score: 0.25,
|
||||
pointer_variance: 0.20,
|
||||
click_rate: 0.25,
|
||||
},
|
||||
step_up: { methods: ['webauthn', 'pow'], primary: 'webauthn' },
|
||||
service_status: 'offline',
|
||||
})
|
||||
}
|
||||
|
||||
// Get admin stats
|
||||
if (action === 'stats') {
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/pca/v1/admin/stats`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response && response.ok) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Return mock stats if service unavailable
|
||||
return NextResponse.json({
|
||||
active_sessions: 0,
|
||||
total_ticks: 0,
|
||||
challenges_issued: 0,
|
||||
challenges_passed: 0,
|
||||
avg_score: 0,
|
||||
humans_detected: 0,
|
||||
bots_detected: 0,
|
||||
service_status: 'offline',
|
||||
})
|
||||
}
|
||||
|
||||
// Get active sessions
|
||||
if (action === 'sessions') {
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/pca/v1/admin/sessions`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response && response.ok) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty sessions if service unavailable
|
||||
return NextResponse.json({
|
||||
sessions: [],
|
||||
total: 0,
|
||||
service_status: 'offline',
|
||||
})
|
||||
}
|
||||
|
||||
// Get ai-access.json configuration
|
||||
if (action === 'ai-access') {
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/pca/v1/admin/ai-access`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response && response.ok) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Return default ai-access config
|
||||
return NextResponse.json({
|
||||
version: '1.0',
|
||||
thresholds: { score_pass: 0.7, score_challenge: 0.4 },
|
||||
weights: {
|
||||
dwell_ratio: 0.30,
|
||||
scroll_score: 0.25,
|
||||
pointer_variance: 0.20,
|
||||
click_rate: 0.25,
|
||||
},
|
||||
step_up: { methods: ['webauthn', 'pow'], primary: 'webauthn' },
|
||||
pca_roles: {
|
||||
Person: { access: 'allow', price: null },
|
||||
Corporate: { access: 'allow', price: '0.01 EUR' },
|
||||
Agent: { access: 'charge', price: '0.001 EUR' },
|
||||
},
|
||||
payment: { enabled: false },
|
||||
service_status: 'offline',
|
||||
})
|
||||
}
|
||||
|
||||
// Default: health check
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/health`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json({
|
||||
status: 'offline',
|
||||
service: 'pca-heuristic-service',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
status: 'offline',
|
||||
service: 'pca-heuristic-service',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Evaluate session or update config
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
// Evaluate a specific session
|
||||
if (action === 'evaluate') {
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'Session ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await safeFetch(
|
||||
`${PCA_SERVICE_URL}/pca/v1/evaluate?session_id=${sessionId}`,
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to evaluate session - service offline' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid response from service' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration (admin only)
|
||||
if (action === 'update-config') {
|
||||
const response = await safeFetch(`${PCA_SERVICE_URL}/pca/v1/admin/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update configuration - service offline' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session (admin only)
|
||||
if (action === 'clear-session') {
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'Session ID required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await safeFetch(
|
||||
`${PCA_SERVICE_URL}/pca/v1/admin/sessions/${sessionId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear session - service offline' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ deleted: true, session_id: sessionId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
215
website/app/api/admin/training/route.ts
Normal file
215
website/app/api/admin/training/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Backend URL - klausur-service
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Helper to proxy requests to backend
|
||||
async function proxyRequest(
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
body?: any
|
||||
): Promise<Response> {
|
||||
const url = `${KLAUSUR_SERVICE_URL}${endpoint}`
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
switch (action) {
|
||||
case 'jobs':
|
||||
response = await proxyRequest('/api/v1/admin/training/jobs')
|
||||
break
|
||||
|
||||
case 'job':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`)
|
||||
break
|
||||
|
||||
case 'models':
|
||||
response = await proxyRequest('/api/v1/admin/training/models')
|
||||
break
|
||||
|
||||
case 'model':
|
||||
if (!versionId) {
|
||||
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`)
|
||||
break
|
||||
|
||||
case 'dataset-stats':
|
||||
response = await proxyRequest('/api/v1/admin/training/dataset/stats')
|
||||
break
|
||||
|
||||
case 'status':
|
||||
response = await proxyRequest('/api/v1/admin/training/status')
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unknown action',
|
||||
validActions: ['jobs', 'job', 'models', 'model', 'dataset-stats', 'status']
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Backend error: ${response.status} - ${errorText}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', detail: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
let body: any = null
|
||||
|
||||
// Parse body if present
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text) {
|
||||
body = JSON.parse(text)
|
||||
}
|
||||
} catch {
|
||||
// No body or invalid JSON
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'create-job':
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Body required for job creation' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest('/api/v1/admin/training/jobs', 'POST', body)
|
||||
break
|
||||
|
||||
case 'pause':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/pause`, 'POST')
|
||||
break
|
||||
|
||||
case 'resume':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/resume`, 'POST')
|
||||
break
|
||||
|
||||
case 'cancel':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/cancel`, 'POST')
|
||||
break
|
||||
|
||||
case 'activate-model':
|
||||
if (!versionId) {
|
||||
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}/activate`, 'POST')
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unknown action',
|
||||
validActions: ['create-job', 'pause', 'resume', 'cancel', 'activate-model']
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
if (jobId) {
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`, 'DELETE')
|
||||
} else if (versionId) {
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`, 'DELETE')
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either job_id or version_id required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
163
website/app/api/admin/uni-crawler/route.ts
Normal file
163
website/app/api/admin/uni-crawler/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* API Proxy for Uni-Crawler (edu-search-service Orchestrator)
|
||||
*
|
||||
* Proxies requests to the edu-search-service orchestrator API
|
||||
* so that the browser doesn't need direct access to the internal service
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Use EDU_SEARCH_URL environment variable - internal Docker URL
|
||||
const EDU_SEARCH_URL = process.env.EDU_SEARCH_URL || 'http://localhost:8086'
|
||||
const EDU_SEARCH_API_KEY = process.env.EDU_SEARCH_API_KEY || 'dev-key'
|
||||
|
||||
// GET: Fetch status, queue, or universities
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let endpoint = ''
|
||||
let requiresAuth = true
|
||||
|
||||
switch (action) {
|
||||
case 'status':
|
||||
endpoint = '/v1/crawl/status'
|
||||
break
|
||||
case 'queue':
|
||||
endpoint = '/v1/crawl/queue'
|
||||
break
|
||||
case 'universities':
|
||||
endpoint = '/api/v1/universities'
|
||||
requiresAuth = false
|
||||
break
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (requiresAuth) {
|
||||
headers['Authorization'] = `Bearer ${EDU_SEARCH_API_KEY}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${EDU_SEARCH_URL}${endpoint}`, { headers })
|
||||
|
||||
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('uni-crawler API GET error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to edu-search-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Start/stop orchestrator, add to queue, etc.
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let endpoint = ''
|
||||
let method = 'POST'
|
||||
let body = null
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
endpoint = '/v1/crawl/start'
|
||||
break
|
||||
case 'stop':
|
||||
endpoint = '/v1/crawl/stop'
|
||||
break
|
||||
case 'queue':
|
||||
endpoint = '/v1/crawl/queue'
|
||||
body = await request.json()
|
||||
break
|
||||
case 'pause':
|
||||
const pauseId = searchParams.get('university_id')
|
||||
endpoint = `/v1/crawl/queue/${pauseId}/pause`
|
||||
break
|
||||
case 'resume':
|
||||
const resumeId = searchParams.get('university_id')
|
||||
endpoint = `/v1/crawl/queue/${resumeId}/resume`
|
||||
break
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${EDU_SEARCH_URL}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${EDU_SEARCH_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorData = { error: errorText }
|
||||
try {
|
||||
errorData = JSON.parse(errorText)
|
||||
} catch {}
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('uni-crawler API POST error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to edu-search-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Remove from queue
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const universityId = searchParams.get('university_id')
|
||||
|
||||
if (!universityId) {
|
||||
return NextResponse.json({ error: 'university_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${EDU_SEARCH_URL}/v1/crawl/queue/${universityId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${EDU_SEARCH_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
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('uni-crawler API DELETE error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to edu-search-service' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
451
website/app/api/admin/unity-bridge/route.ts
Normal file
451
website/app/api/admin/unity-bridge/route.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const UNITY_BRIDGE_URL = process.env.UNITY_BRIDGE_URL || 'http://localhost:8090'
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
// Backend type for routing
|
||||
type BackendType = 'unity' | 'python'
|
||||
|
||||
interface EndpointConfig {
|
||||
path: string
|
||||
backend: BackendType
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
// Python Backend endpoints for Unit System
|
||||
const pythonEndpoints: Record<string, EndpointConfig> = {
|
||||
// Unit definitions
|
||||
'units-list': { path: '/api/units/definitions', backend: 'python' },
|
||||
'units-health': { path: '/api/units/health', backend: 'python' },
|
||||
// Analytics
|
||||
'analytics-overview': { path: '/api/analytics/dashboard/overview', backend: 'python' },
|
||||
'analytics-misconceptions': { path: '/api/analytics/misconceptions', backend: 'python' },
|
||||
// Teacher dashboard
|
||||
'teacher-dashboard': { path: '/api/teacher/dashboard', backend: 'python' },
|
||||
'teacher-units': { path: '/api/teacher/units/available', backend: 'python' },
|
||||
}
|
||||
|
||||
// Streaming types
|
||||
interface StreamStatusResponse {
|
||||
is_streaming: boolean
|
||||
frame_count: number
|
||||
width: number
|
||||
height: number
|
||||
quality: number
|
||||
uptime_seconds: string
|
||||
}
|
||||
|
||||
interface StreamFrameResponse {
|
||||
success: boolean
|
||||
frame_count?: number
|
||||
width?: number
|
||||
height?: number
|
||||
data?: string // Base64 encoded JPEG
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
interface BridgeStatus {
|
||||
status: string
|
||||
unity_version: string
|
||||
project: string
|
||||
scene: string
|
||||
is_playing: boolean
|
||||
is_compiling: boolean
|
||||
errors: number
|
||||
warnings: number
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
time: string
|
||||
type: string
|
||||
message: string
|
||||
frame: number
|
||||
stack?: string
|
||||
}
|
||||
|
||||
interface LogsResponse {
|
||||
count: number
|
||||
total_errors: number
|
||||
total_warnings: number
|
||||
total_info: number
|
||||
logs: LogEntry[]
|
||||
}
|
||||
|
||||
interface DiagnosticEntry {
|
||||
category: string
|
||||
severity: 'ok' | 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface DiagnoseResponse {
|
||||
diagnostics: DiagnosticEntry[]
|
||||
errors: number
|
||||
warnings: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/unity-bridge
|
||||
*
|
||||
* Proxy requests to the Unity AI Bridge running in the Unity Editor.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - action: The endpoint to call (status, compile, logs, errors, warnings, scene, play, stop, quicksetup)
|
||||
* - limit: For logs, the number of entries to return (default: 50)
|
||||
* - name: For object endpoint, the name of the GameObject
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action') || 'status'
|
||||
const limit = searchParams.get('limit') || '50'
|
||||
const objectName = searchParams.get('name')
|
||||
const unitId = searchParams.get('unit_id')
|
||||
const timeRange = searchParams.get('time_range') || 'month'
|
||||
|
||||
// Check if this is a Python backend action
|
||||
if (pythonEndpoints[action]) {
|
||||
return handlePythonBackend(action, searchParams)
|
||||
}
|
||||
|
||||
// Dynamic Python backend actions (with parameters)
|
||||
if (action === 'units-get' && unitId) {
|
||||
return fetchFromPython(`/api/units/definitions/${unitId}`)
|
||||
}
|
||||
if (action === 'analytics-learning-gain' && unitId) {
|
||||
return fetchFromPython(`/api/analytics/learning-gain/${unitId}?time_range=${timeRange}`)
|
||||
}
|
||||
if (action === 'analytics-stops' && unitId) {
|
||||
return fetchFromPython(`/api/analytics/unit/${unitId}/stops?time_range=${timeRange}`)
|
||||
}
|
||||
if (action === 'content-h5p' && unitId) {
|
||||
return fetchFromPython(`/api/units/content/${unitId}/h5p`)
|
||||
}
|
||||
if (action === 'content-worksheet' && unitId) {
|
||||
return fetchFromPython(`/api/units/content/${unitId}/worksheet`)
|
||||
}
|
||||
if (action === 'content-pdf' && unitId) {
|
||||
return fetchPdfFromPython(`/api/units/content/${unitId}/worksheet.pdf`)
|
||||
}
|
||||
|
||||
// Map actions to Unity Bridge endpoints
|
||||
const endpointMap: Record<string, string> = {
|
||||
status: '/status',
|
||||
compile: '/compile',
|
||||
logs: `/logs?limit=${limit}`,
|
||||
errors: '/logs/errors',
|
||||
warnings: '/logs/warnings',
|
||||
scene: '/scene',
|
||||
selection: '/selection',
|
||||
play: '/play',
|
||||
stop: '/stop',
|
||||
quicksetup: '/quicksetup',
|
||||
// Streaming endpoints
|
||||
'stream-start': '/stream/start',
|
||||
'stream-stop': '/stream/stop',
|
||||
'stream-frame': '/stream/frame',
|
||||
'stream-status': '/stream/status',
|
||||
}
|
||||
|
||||
// Handle screenshot endpoint - returns binary image data
|
||||
if (action === 'screenshot') {
|
||||
try {
|
||||
const response = await fetch(`${UNITY_BRIDGE_URL}/screenshot`, {
|
||||
headers: { 'Accept': 'image/jpeg' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Screenshot failed with status ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const imageBuffer = await response.arrayBuffer()
|
||||
return new NextResponse(imageBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json(
|
||||
{ error: 'Screenshot fehlgeschlagen', details: errorMessage, offline: true },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle object endpoint separately (needs name parameter)
|
||||
let endpoint: string
|
||||
if (action === 'object' && objectName) {
|
||||
endpoint = `/object/${encodeURIComponent(objectName)}`
|
||||
} else {
|
||||
endpoint = endpointMap[action]
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown action', validActions: Object.keys(endpointMap) },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${UNITY_BRIDGE_URL}${endpoint}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
// Short timeout since it's localhost
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unity Bridge returned ${response.status}`, offline: false },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
// Distinguish between timeout and connection refused
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
const isTimeout = errorMessage.includes('timeout') || errorMessage.includes('aborted')
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isTimeout
|
||||
? 'Unity Bridge timed out - Unity might be busy'
|
||||
: 'Unity Bridge nicht erreichbar - Server in Unity starten',
|
||||
offline: true,
|
||||
details: errorMessage,
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/unity-bridge
|
||||
*
|
||||
* Execute commands on the Unity Bridge.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - action: The command to execute (diagnose, execute, clear-logs)
|
||||
*
|
||||
* For execute action, the body should contain:
|
||||
* - action: The execute action (select, setactive, delete, create, menu, log)
|
||||
* - name: GameObject name (for select, setactive, delete)
|
||||
* - active: true/false (for setactive)
|
||||
* - type: Object type (for create: cube, sphere, empty, etc.)
|
||||
* - path: Menu path (for menu action)
|
||||
* - message: Log message (for log action)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
if (action === 'diagnose') {
|
||||
try {
|
||||
const response = await fetch(`${UNITY_BRIDGE_URL}/diagnose`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
// Diagnose can take longer
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Diagnose failed with status ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data: DiagnoseResponse = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json(
|
||||
{ error: 'Diagnose fehlgeschlagen', details: errorMessage },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'execute') {
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate required fields based on execute action
|
||||
const executeAction = body.action as string
|
||||
if (!executeAction) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing "action" field in body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${UNITY_BRIDGE_URL}/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Execute failed: ${errorData}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json(
|
||||
{ error: 'Execute fehlgeschlagen', details: errorMessage },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'clear-logs') {
|
||||
try {
|
||||
const response = await fetch(`${UNITY_BRIDGE_URL}/logs/clear`, {
|
||||
method: 'GET', // Unity Bridge uses GET for clear
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json(
|
||||
{ error: 'Clear logs fehlgeschlagen', details: errorMessage },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown POST action', validActions: ['diagnose', 'execute', 'clear-logs'] },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Python Backend Helper Functions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Handle requests to Python backend endpoints
|
||||
*/
|
||||
async function handlePythonBackend(
|
||||
action: string,
|
||||
searchParams: URLSearchParams
|
||||
): Promise<NextResponse> {
|
||||
const config = pythonEndpoints[action]
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown Python backend action' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Build query string from search params (excluding action)
|
||||
const queryParams = new URLSearchParams()
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== 'action') {
|
||||
queryParams.set(key, value)
|
||||
}
|
||||
})
|
||||
const queryString = queryParams.toString()
|
||||
const url = queryString ? `${config.path}?${queryString}` : config.path
|
||||
|
||||
return fetchFromPython(url, config.timeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from Python backend
|
||||
*/
|
||||
async function fetchFromPython(
|
||||
path: string,
|
||||
timeout: number = 5000
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}${path}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: `Backend returned ${response.status}`, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
const isTimeout = errorMessage.includes('timeout') || errorMessage.includes('aborted')
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isTimeout
|
||||
? 'Backend timed out'
|
||||
: 'Backend nicht erreichbar - Server starten mit: cd backend && python main.py',
|
||||
offline: true,
|
||||
details: errorMessage,
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PDF from Python backend
|
||||
*/
|
||||
async function fetchPdfFromPython(path: string): Promise<NextResponse> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}${path}`, {
|
||||
headers: { 'Accept': 'application/pdf' },
|
||||
signal: AbortSignal.timeout(15000), // PDF generation can take longer
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `PDF generation failed with status ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const pdfBuffer = await response.arrayBuffer()
|
||||
return new NextResponse(pdfBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'attachment; filename="worksheet.pdf"',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json(
|
||||
{ error: 'PDF generation fehlgeschlagen', details: errorMessage },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
219
website/app/api/admin/zeugnisse-crawler/route.ts
Normal file
219
website/app/api/admin/zeugnisse-crawler/route.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Backend URL - klausur-service
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Helper to proxy requests to backend
|
||||
async function proxyRequest(
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
body?: any
|
||||
): Promise<Response> {
|
||||
const url = `${KLAUSUR_SERVICE_URL}${endpoint}`
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const bundesland = searchParams.get('bundesland')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
switch (action) {
|
||||
case 'stats':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/stats')
|
||||
break
|
||||
|
||||
case 'bundesland-stats':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/stats/bundesland')
|
||||
break
|
||||
|
||||
case 'status':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/crawler/status')
|
||||
break
|
||||
|
||||
case 'queue':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/crawler/queue')
|
||||
break
|
||||
|
||||
case 'sources':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/sources')
|
||||
break
|
||||
|
||||
case 'documents':
|
||||
const docsEndpoint = bundesland
|
||||
? `/api/v1/admin/zeugnis/documents?bundesland=${bundesland}`
|
||||
: '/api/v1/admin/zeugnis/documents'
|
||||
response = await proxyRequest(docsEndpoint)
|
||||
break
|
||||
|
||||
case 'audit':
|
||||
const days = searchParams.get('days') || '30'
|
||||
response = await proxyRequest(`/api/v1/admin/zeugnis/audit/events?days=${days}`)
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown action', validActions: ['stats', 'bundesland-stats', 'status', 'queue', 'sources', 'documents', 'audit'] },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Backend error: ${response.status} - ${errorText}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', detail: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Zeugnis API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
let body: any = null
|
||||
|
||||
// Parse body if present
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text) {
|
||||
body = JSON.parse(text)
|
||||
}
|
||||
} catch {
|
||||
// No body or invalid JSON
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
response = await proxyRequest(
|
||||
'/api/v1/admin/zeugnis/crawler/start',
|
||||
'POST',
|
||||
body || {}
|
||||
)
|
||||
break
|
||||
|
||||
case 'stop':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/crawler/stop', 'POST')
|
||||
break
|
||||
|
||||
case 'init':
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/init', 'POST')
|
||||
break
|
||||
|
||||
case 'add-to-queue':
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Body required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/crawler/queue', 'POST', body)
|
||||
break
|
||||
|
||||
case 'add-source':
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Body required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest('/api/v1/admin/zeugnis/sources', 'POST', body)
|
||||
break
|
||||
|
||||
case 'verify-source':
|
||||
const sourceId = searchParams.get('source_id')
|
||||
if (!sourceId || !body) {
|
||||
return NextResponse.json({ error: 'source_id and body required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(
|
||||
`/api/v1/admin/zeugnis/sources/${sourceId}/verify`,
|
||||
'PUT',
|
||||
body
|
||||
)
|
||||
break
|
||||
|
||||
case 'add-url':
|
||||
const urlSourceId = searchParams.get('source_id')
|
||||
if (!urlSourceId || !body) {
|
||||
return NextResponse.json({ error: 'source_id and body required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(
|
||||
`/api/v1/admin/zeugnis/sources/${urlSourceId}/urls`,
|
||||
'POST',
|
||||
body
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Unknown action', validActions: ['start', 'stop', 'init', 'add-to-queue', 'add-source', 'verify-source', 'add-url'] },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Zeugnis API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const urlId = searchParams.get('url_id')
|
||||
|
||||
if (!urlId) {
|
||||
return NextResponse.json({ error: 'url_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyRequest(`/api/v1/admin/zeugnis/urls/${urlId}`, 'DELETE')
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Zeugnis API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user