feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리

예전 배포본(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
This commit is contained in:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

View file

@ -3,21 +3,61 @@
import { Box, Typography, Button } from '@mui/material'
import { fetchServerStats } from '@/lib/api-server'
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { fetchSubscriptionRevenue, type SubscriptionRevenue } from '@/lib/subscription-metrics'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, StatRing, TactileBadge } from '@d3ro/ui/components/ds'
import { DashboardSimulator } from '@/components/dashboard-simulator'
import Link from 'next/link'
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
const stats = await fetchServerStats()
const nonOperationalNodes = stats.nodes.filter((node) => node.status !== 'operational')
let revenue: SubscriptionRevenue | null = null
if (isSupabaseAdminConfigured()) {
try {
revenue = await fetchSubscriptionRevenue()
} catch {
revenue = null
}
}
const revenueCards = revenue
? [
{
title: 'Annual Recurring Revenue',
value: `$${revenue.arrUsd.toLocaleString()}`,
subtext: 'Active subscriptions × 12 months',
color: 'green' as const,
badge: 'ARR',
badgeColor: 'green' as const,
},
{
title: 'Monthly Recurring Revenue',
value: `$${revenue.mrrUsd.toLocaleString()}`,
subtext: `Pro ${revenue.tierBreakdown.pro.toLocaleString()} · Pro+ ${revenue.tierBreakdown.pro_plus.toLocaleString()}`,
color: 'blue' as const,
badge: 'MRR',
badgeColor: 'blue' as const,
},
{
title: 'Active Subscriptions',
value: revenue.activeCount.toLocaleString(),
subtext: 'Supabase subscriptions with status = active',
color: 'purple' as const,
badge: 'BILLING',
badgeColor: 'purple' as const,
},
]
: []
const bentoCards = [
{
title: 'Annual Recurring Revenue (ARR)',
value: `$${stats.arrUsd.toLocaleString()}`,
subtext: `MRR: $${stats.mrrUsd.toLocaleString()} • +18.4% MoM Growth`,
title: 'Backend Uptime',
value: `${Math.floor(stats.serverUptimeSeconds / 3600).toLocaleString()}h`,
subtext: 'Measured by the active .NET API process',
color: 'purple' as const,
badge: 'REVENUE',
badge: 'RUNTIME',
badgeColor: 'purple' as const,
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -26,11 +66,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
),
},
{
title: 'Active Voice & Meeting Sessions',
title: 'Active Admin Accounts Today',
value: `${stats.activeUsersToday.toLocaleString()} Active`,
subtext: `${stats.totalUsers.toLocaleString()} Total Users • 18 Realtime Streams`,
subtext: `${stats.totalUsers.toLocaleString()} administrator accounts`,
color: 'blue' as const,
badge: 'VOICE STREAMS',
badge: 'AUTH',
badgeColor: 'blue' as const,
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -52,11 +92,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
),
},
{
title: 'Speaker Diarization Accuracy',
value: `${stats.pipelines.meetingIntelligence.speakerAccuracyPercent}%`,
subtext: `${stats.pipelines.meetingIntelligence.templatesGeneratedToday} Meeting Docs • 42 Mindmaps`,
title: 'Recorded Backend Errors',
value: stats.errorCount.toLocaleString(),
subtext: 'Persisted server error log entries',
color: 'orange' as const,
badge: 'PHASE 15.5',
badge: 'ERROR LOG',
badgeColor: 'orange' as const,
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -98,7 +138,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -107,7 +147,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
Unified Dashboard Overview
</Typography>
<TactileBadge ledColor="green" ledPulse tone="success" mono>
ONLINE v0.2.1-alpha
BACKEND CONNECTED
</TactileBadge>
</Box>
<Typography
@ -119,7 +159,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
mt: 0.25,
}}
>
REALTIME AI TELEMETRY ARR & SUBSCRIPTION METRICS PIPELINE HEALTH
Backend runtime counters, error ledger, reported node health
</Typography>
</Box>
</Box>
@ -149,6 +189,70 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
{/* Main Content Area */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3.5 }}>
{/* Revenue KPI Row */}
{revenueCards.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
gap: 2.5,
}}
>
{revenueCards.map((card) => (
<DoubleBezelCard
key={card.title}
interactive
bezelPadding="5px"
innerPadding="20px"
sx={{ height: '100%' }}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<StatRing color={card.color} size={46}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500 }}>$</Typography>
</StatRing>
<Box component="span" sx={statusBadgeSx(card.badgeColor)}>
{card.badge}
</Box>
</Box>
<Typography
sx={{
fontFamily: FONT_SANS,
fontSize: '11px',
fontWeight: 500,
color: C.dim,
letterSpacing: '0.04em',
}}
>
{card.title}
</Typography>
<Typography
sx={{
fontFamily: FONT_MONO,
fontSize: '26px',
fontWeight: 500,
color: C.bright,
my: 0.75,
}}
>
{card.value}
</Typography>
<Typography
sx={{
fontFamily: FONT_SANS,
fontSize: '11px',
color: C.text,
}}
>
{card.subtext}
</Typography>
</DoubleBezelCard>
))}
</Box>
)}
{/* Executive Bento Grid */}
<Box
sx={{
@ -178,10 +282,9 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_SANS,
fontSize: '11px',
fontWeight: 600,
fontWeight: 500,
color: C.dim,
textTransform: 'uppercase',
letterSpacing: '0.06em',
letterSpacing: '0.04em',
}}
>
{card.title}
@ -191,7 +294,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_MONO,
fontSize: '26px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
my: 0.75,
}}
@ -216,15 +319,19 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
System Nodes & Pipeline Topology
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
6 NODES HEALTHY ZERO SERVICE DEGRADATION DETECTED
{stats.nodes.length === 0
? 'NODE TELEMETRY NOT REPORTED'
: `${stats.nodes.length} REPORTED • ${nonOperationalNodes.length} NON-OPERATIONAL`}
</Typography>
</Box>
<TactileBadge tone="success" mono>
ALL OPERATIONAL
<TactileBadge tone="mono" mono>
{stats.nodes.length === 0
? 'UNAVAILABLE'
: nonOperationalNodes.length === 0 ? 'ALL REPORTED OPERATIONAL' : 'ATTENTION REQUIRED'}
</TactileBadge>
</Box>
@ -235,7 +342,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
gap: 2,
}}
>
{stats.nodes.map((node) => (
{stats.nodes.length === 0 ? (
<Typography sx={{ gridColumn: '1 / -1', py: 3, textAlign: 'center', color: C.dim, fontFamily: FONT_MONO, fontSize: '12px' }}>
No node-health telemetry has been reported by the backend.
</Typography>
) : stats.nodes.map((node) => (
<Box
key={node.id}
sx={{
@ -252,7 +363,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 500, color: C.bright }}>
{node.name}
</Typography>
<Box
@ -260,8 +371,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: '#10b981',
boxShadow: '0 0 8px #10b981',
bgcolor: node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444',
boxShadow: `0 0 8px ${node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444'}`,
}}
/>
</Box>
@ -274,8 +385,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
Latency: <strong style={{ color: C.bright }}>{node.latencyMs}ms</strong>
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.green400 }}>
{node.uptimePercent}% Up
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: node.status === 'operational' ? C.green400 : node.status === 'degraded' ? C.orange400 : C.red400 }}>
{node.uptimePercent}% · {node.status.toUpperCase()}
</Typography>
</Box>
</Box>
@ -283,17 +394,14 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
</Box>
</DoubleBezelCard>
{/* Live Audio & Voice Intelligence Simulator Widget */}
<DashboardSimulator />
{/* Server Operational Telemetry Logs */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
Operational Telemetry & Server Logs
</Typography>
<TactileBadge tone="mono" mono>
AUTO REFRESH (30s)
SERVER SNAPSHOT
</TactileBadge>
</Box>
@ -312,7 +420,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
{stats.recentErrors.length === 0 ? (
<Box component="tr">
<Box component="td" colSpan={5} sx={{ textAlign: 'center', color: C.green400, py: 3 }}>
NO OPERATIONAL ERRORS ALL C# .NET API NODES HEALTHY (100% SUCCESS RATE)
No backend errors have been recorded.
</Box>
</Box>
) : (