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:
parent
a9c9a1ca6e
commit
5a34f66981
66 changed files with 4471 additions and 3501 deletions
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue