예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase) 위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는 인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음. 인증/세션 - 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지 - ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로 로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example) - Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel 기능 복원 (실데이터) - Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력) - Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계 - License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용), 개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록 - Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움 - 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반) - 사용자 상세 티어별 기능 배지(pro_plus 조건부) .NET - SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
239 lines
8.5 KiB
TypeScript
239 lines
8.5 KiB
TypeScript
import 'server-only'
|
|
|
|
import { fetchAdminBackend } from './backend-session'
|
|
|
|
export interface SystemNodeHealth {
|
|
id: string
|
|
name: string
|
|
category: 'stt' | 'llm' | 'voice_realtime' | 'rag_vector' | 'backend_api' | 'diarization'
|
|
status: 'operational' | 'degraded' | 'offline'
|
|
latencyMs: number
|
|
uptimePercent: number
|
|
versionOrModel: string
|
|
vramOrMemory: string
|
|
details: string
|
|
}
|
|
|
|
export interface PipelineStats {
|
|
whisper: { engine: string; activeModel: string; avgLatencyMs: number; speedupFactor: string; partialStreamingFps: number; totalTranscriptionsToday: number; gpuVramUsage: string }
|
|
ollama: { version: string; loadedModels: string[]; activeContextLimit: number; tokensPerSecond: number; vramAllocated: string; activeSessions: number }
|
|
realtimeVoice: { backend: string; activeStreams: number; streamUptime: number; localFallbackRate: string; avgAudioRttMs: number }
|
|
ragVector: { embeddingModel: string; indexedDocuments: number; totalVectorChunks: number; avgSearchLatencyMs: number; topHitRatePercent: number }
|
|
meetingIntelligence: { diarizationEngine: string; speakerAccuracyPercent: number; activeMeetingSessions: number; templatesGeneratedToday: number; mindmapsExported: number }
|
|
}
|
|
|
|
export interface FeatureUsageBreakdown {
|
|
featureId: string
|
|
featureName: string
|
|
category: string
|
|
totalCalls?: number
|
|
callCount?: number
|
|
percentage?: number
|
|
tokensUsed: number
|
|
totalCost: number
|
|
estimatedCostUsd?: number
|
|
avgLatencyMs: number
|
|
}
|
|
|
|
export interface ServerStats {
|
|
totalUsers: number
|
|
activeUsersToday: number
|
|
totalRequests: number
|
|
totalCost: number
|
|
serverUptimeSeconds: number
|
|
errorCount: number
|
|
arrUsd: number | null
|
|
mrrUsd: number | null
|
|
tierDistribution: { free: number; pro: number; pro_plus: number } | null
|
|
nodes: SystemNodeHealth[]
|
|
pipelines: PipelineStats | null
|
|
featureBreakdown: FeatureUsageBreakdown[]
|
|
recentErrors: Array<{ id: number; errorType: string; message: string; endpoint: string | null; createdAt: string }>
|
|
}
|
|
|
|
export interface UserItem {
|
|
id: string
|
|
uid: string
|
|
email: string
|
|
name: string
|
|
role: 'user' | 'manager' | 'admin' | 'super_admin'
|
|
tier: 'free' | 'pro' | 'pro_plus' | null
|
|
createdAt: string
|
|
lastLoginAt: string | null
|
|
lastActiveDevice: string | null
|
|
isActive: boolean
|
|
dailyUsage: { dictations: number; dictationsMax: number | null; llmCalls: number; llmCallsMax: number | null; ragQueries: number; ragQueriesMax: number | null } | null
|
|
}
|
|
|
|
export interface ModelEndpoint {
|
|
id: number
|
|
modelId: string
|
|
modelName: string
|
|
provider: 'OpenAI' | 'Ollama Local' | 'Anthropic' | 'Local Sidecar' | 'DeepSeek' | 'Custom'
|
|
endpointUrl: string
|
|
apiKey: string
|
|
costPer1kPromptTokens: number
|
|
costPer1kCompletionTokens: number
|
|
latencyMs?: number
|
|
isActive: boolean
|
|
isDefault?: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export type STTProviderCategory = 'groq' | 'openai' | 'deepgram' | 'google' | 'assemblyai' | 'azure' | 'custom' | 'local-sidecar'
|
|
|
|
export interface SttProviderEndpoint {
|
|
id: number
|
|
name: string
|
|
providerType: STTProviderCategory
|
|
endpointUrl: string
|
|
apiKey: string
|
|
modelId: string
|
|
method: 'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'
|
|
language: string
|
|
prompt: string | null
|
|
temperature: number
|
|
costPerMinute: number
|
|
costPerSecond: number
|
|
isDefault: boolean
|
|
isActive: boolean
|
|
fallbackPriority: number
|
|
extraHeadersJson: string | null
|
|
latencyMs?: number
|
|
createdAt: string
|
|
updatedAt: string | null
|
|
}
|
|
|
|
export interface CreateSttEndpointDto {
|
|
name: string
|
|
providerType: STTProviderCategory
|
|
endpointUrl: string
|
|
apiKey?: string
|
|
modelId: string
|
|
method: string
|
|
language?: string
|
|
prompt?: string
|
|
temperature?: number
|
|
costPerMinute: number
|
|
costPerSecond?: number
|
|
isDefault?: boolean
|
|
isActive?: boolean
|
|
fallbackPriority?: number
|
|
extraHeadersJson?: string
|
|
memo: string
|
|
}
|
|
|
|
export type UpdateSttEndpointDto = CreateSttEndpointDto
|
|
|
|
export interface SttTestResult {
|
|
success: boolean
|
|
message: string
|
|
latencyMs: number
|
|
transcriptPreview: string | null
|
|
provider: string | null
|
|
modelId: string | null
|
|
}
|
|
|
|
export interface SttUsageReport {
|
|
totalTranscriptions: number
|
|
totalAudioMinutes: number
|
|
totalCost: number
|
|
avgLatencyMs: number
|
|
providerSummaries: Array<{ provider: string; modelId: string; totalRequests: number; totalAudioMinutes: number; totalCost: number; avgLatencyMs: number }>
|
|
userSummaries: Array<{ userId: number; email: string; totalRequests: number; totalAudioMinutes: number; totalCost: number }>
|
|
}
|
|
|
|
export interface UsageReport {
|
|
totalRequests: number
|
|
totalPromptTokens: number
|
|
totalCompletionTokens: number
|
|
totalCost: number
|
|
timeline: Array<{ date: string; dictations: number; meetingSummaries: number; aiChat: number; ragSearch: number; voiceRealtime: number; totalCost: number }>
|
|
features: FeatureUsageBreakdown[]
|
|
userSummaries: Array<{ userId: number; email: string; name?: string; tier?: string; totalRequests: number; totalTokens: number; totalCost: number }>
|
|
modelSummaries: Array<{ modelId: string; modelName: string; provider?: string; totalRequests: number; totalTokens: number; totalCost: number }>
|
|
}
|
|
|
|
async function readJson<T>(path: string): Promise<T> {
|
|
const response = await fetchAdminBackend(path)
|
|
if (!response.ok) throw new Error(`Admin backend request failed (${response.status})`)
|
|
try {
|
|
return (await response.json()) as T
|
|
} catch {
|
|
throw new Error('Admin backend returned invalid JSON')
|
|
}
|
|
}
|
|
|
|
function normalizeRole(value: unknown): UserItem['role'] {
|
|
const normalized = typeof value === 'string' ? value.replace(/[_-]/g, '').toLowerCase() : ''
|
|
if (normalized === 'superadmin') return 'super_admin'
|
|
if (normalized === 'admin') return 'admin'
|
|
if (normalized === 'manager') return 'manager'
|
|
return 'user'
|
|
}
|
|
|
|
export async function fetchServerStats(): Promise<ServerStats> {
|
|
const data = await readJson<Partial<ServerStats>>('/stats')
|
|
if (typeof data.totalUsers !== 'number' || typeof data.activeUsersToday !== 'number' || typeof data.totalRequests !== 'number' || typeof data.totalCost !== 'number' || typeof data.serverUptimeSeconds !== 'number' || typeof data.errorCount !== 'number') {
|
|
throw new Error('Admin stats response is incomplete')
|
|
}
|
|
return {
|
|
totalUsers: data.totalUsers,
|
|
activeUsersToday: data.activeUsersToday,
|
|
totalRequests: data.totalRequests,
|
|
totalCost: data.totalCost,
|
|
serverUptimeSeconds: data.serverUptimeSeconds,
|
|
errorCount: data.errorCount,
|
|
arrUsd: typeof data.arrUsd === 'number' ? data.arrUsd : null,
|
|
mrrUsd: typeof data.mrrUsd === 'number' ? data.mrrUsd : null,
|
|
tierDistribution: data.tierDistribution ?? null,
|
|
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
|
pipelines: data.pipelines ?? null,
|
|
featureBreakdown: Array.isArray(data.featureBreakdown) ? data.featureBreakdown : [],
|
|
recentErrors: Array.isArray(data.recentErrors) ? data.recentErrors : []
|
|
}
|
|
}
|
|
|
|
export async function fetchUsers(): Promise<UserItem[]> {
|
|
const data = await readJson<Array<Record<string, unknown>>>('/users')
|
|
if (!Array.isArray(data)) throw new Error('Admin users response is invalid')
|
|
return data.map((item) => {
|
|
if (typeof item.id !== 'number' || typeof item.email !== 'string' || typeof item.createdAt !== 'string') {
|
|
throw new Error('Admin users response contains an invalid row')
|
|
}
|
|
return {
|
|
id: String(item.id),
|
|
uid: String(item.id),
|
|
email: item.email,
|
|
name: item.email,
|
|
role: normalizeRole(item.role),
|
|
tier: null,
|
|
createdAt: item.createdAt,
|
|
lastLoginAt: typeof item.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
|
lastActiveDevice: null,
|
|
isActive: item.isActive === true,
|
|
dailyUsage: null
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function fetchModelEndpoints(): Promise<ModelEndpoint[]> {
|
|
const data = await readJson<ModelEndpoint[]>('/endpoints')
|
|
if (!Array.isArray(data)) throw new Error('Model endpoints response is invalid')
|
|
return data
|
|
}
|
|
|
|
export async function fetchSttEndpoints(): Promise<SttProviderEndpoint[]> {
|
|
const data = await readJson<SttProviderEndpoint[]>('/stt-endpoints')
|
|
if (!Array.isArray(data)) throw new Error('STT endpoints response is invalid')
|
|
return data
|
|
}
|
|
|
|
export async function fetchSttUsageReport(): Promise<SttUsageReport> {
|
|
return readJson<SttUsageReport>('/stt-usage')
|
|
}
|
|
|
|
export async function fetchUsageReport(): Promise<UsageReport> {
|
|
const data = await readJson<Omit<UsageReport, 'timeline' | 'features'> & Partial<Pick<UsageReport, 'timeline' | 'features'>>>('/usage')
|
|
return { ...data, timeline: Array.isArray(data.timeline) ? data.timeline : [], features: Array.isArray(data.features) ? data.features : [] }
|
|
}
|