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
|
|
@ -1,397 +1,353 @@
|
|||
// apps/admin/src/app/(admin)/releases/page.tsx
|
||||
// D3RO Voice Admin CRM — Release & Distribution Management Hub
|
||||
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
|
||||
|
||||
'use client'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import Link from 'next/link'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import { fetchReleaseHub, type ReleaseAssetPlatform, type ReleaseHub } from '@/lib/forgejo-releases'
|
||||
import { ChecksumCopy } from '@/components/checksum-copy'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Box, Typography, Button, LinearProgress, Switch } from '@mui/material'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react'
|
||||
import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface ReleaseAsset {
|
||||
id: string
|
||||
name: string
|
||||
os: 'Windows' | 'macOS' | 'Linux' | 'Feed'
|
||||
version: string
|
||||
sizeMb: number
|
||||
downloads: number
|
||||
sha256: string
|
||||
status: 'active' | 'deprecated' | 'archived'
|
||||
url: string
|
||||
const PLATFORM_LABEL: Record<ReleaseAssetPlatform, string> = {
|
||||
windows: 'Windows',
|
||||
macos: 'macOS',
|
||||
android: 'Android',
|
||||
linux: 'Linux',
|
||||
feed: 'Update Feed',
|
||||
other: 'Other'
|
||||
}
|
||||
|
||||
const INITIAL_ASSETS: ReleaseAsset[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'D3RO-Voice-Setup-1.0.0-x64.exe',
|
||||
os: 'Windows',
|
||||
version: '1.0.0',
|
||||
sizeMb: 102.1,
|
||||
downloads: 1420,
|
||||
sha256: 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2',
|
||||
status: 'active',
|
||||
url: '/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'D3RO-Voice-1.0.0-arm64.dmg',
|
||||
os: 'macOS',
|
||||
version: '1.0.0',
|
||||
sizeMb: 98.4,
|
||||
downloads: 890,
|
||||
sha256: 'd9f28a391c49b1a03982e0192847192837491823749182374918237491823749',
|
||||
status: 'active',
|
||||
url: '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'latest.yml',
|
||||
os: 'Feed',
|
||||
version: '1.0.0',
|
||||
sizeMb: 0.01,
|
||||
downloads: 4820,
|
||||
sha256: '795b7230bec047284785f480026d51b9247d8618e083d88cfda786d1894ca367',
|
||||
status: 'active',
|
||||
url: '/releases/latest.yml',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'D3RO-Voice-Setup-0.2.1-alpha-x64.exe',
|
||||
os: 'Windows',
|
||||
version: '0.2.1-alpha',
|
||||
sizeMb: 99.8,
|
||||
downloads: 620,
|
||||
sha256: '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b',
|
||||
status: 'deprecated',
|
||||
url: '/releases/0.2.1-alpha/D3RO-Voice-Setup-0.2.1-alpha-x64.exe',
|
||||
},
|
||||
]
|
||||
const PLATFORM_BADGE: Record<ReleaseAssetPlatform, 'blue' | 'purple' | 'green' | 'orange' | 'cyan'> = {
|
||||
windows: 'blue',
|
||||
macos: 'purple',
|
||||
android: 'green',
|
||||
linux: 'orange',
|
||||
feed: 'cyan',
|
||||
other: 'orange'
|
||||
}
|
||||
|
||||
export default function ReleasesManagementPage(): React.ReactElement {
|
||||
const [assets] = useState<ReleaseAsset[]>(INITIAL_ASSETS)
|
||||
const [rolloutPercent, setRolloutPercent] = useState<number>(100)
|
||||
const [forceUpdateEnabled, setForceUpdateEnabled] = useState<boolean>(false)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
function formatSize(sizeBytes: number): string {
|
||||
if (sizeBytes <= 0) return '—'
|
||||
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`
|
||||
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, id: string): void => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
})
|
||||
}
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const totalDownloads = assets.reduce((acc, curr) => acc + curr.downloads, 0)
|
||||
function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl: string }): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: `linear-gradient(180deg, ${C.cyan} 0%, ${C.accent} 50%, ${C.purple} 100%)`
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
|
||||
>
|
||||
Release & Distribution Hub
|
||||
</Typography>
|
||||
{feedLive ? (
|
||||
<TactileBadge ledColor="green" ledPulse tone="success" mono>
|
||||
LIVE FORGEJO FEED
|
||||
</TactileBadge>
|
||||
) : (
|
||||
<TactileBadge tone="warning" mono>
|
||||
FEED UNREACHABLE
|
||||
</TactileBadge>
|
||||
)}
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
|
||||
Desktop & mobile installers, SHA-256 integrity, download telemetry
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`${repoHtmlUrl}/releases`}
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: C.text,
|
||||
borderColor: C.border,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { borderColor: C.accentLight, color: C.bright }
|
||||
}}
|
||||
>
|
||||
Forgejo Releases ↗
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href="https://d3ro.chanpaca.net/download.html"
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: C.cyanLight,
|
||||
borderColor: C.border,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { borderColor: C.cyanLight, color: C.bright }
|
||||
}}
|
||||
>
|
||||
Public Download Page ↗
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiCards({ hub }: { hub: ReleaseHub }): React.ReactElement {
|
||||
const latest = hub.latestStable
|
||||
const latestPlatforms = [...new Set(latest?.assets.map((asset) => asset.platform) ?? [])].filter(
|
||||
(platform) => platform !== 'other'
|
||||
)
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Asset Downloads',
|
||||
value: hub.totalDownloads.toLocaleString(),
|
||||
subtext: 'Forgejo attachment download counters'
|
||||
},
|
||||
{
|
||||
title: 'Latest Stable Release',
|
||||
value: latest?.tagName ?? '—',
|
||||
subtext: latest ? `Published ${formatDate(latest.publishedAt)}` : 'No stable release published'
|
||||
},
|
||||
{
|
||||
title: 'Published Releases',
|
||||
value: hub.releases.length.toLocaleString(),
|
||||
subtext: `${hub.prereleaseCount.toLocaleString()} pre-release channel builds`
|
||||
},
|
||||
{
|
||||
title: 'Latest Platform Coverage',
|
||||
value: latestPlatforms.length ? latestPlatforms.map((platform) => PLATFORM_LABEL[platform]).join(' · ') : '—',
|
||||
subtext: latest ? `${latest.assets.length.toLocaleString()} packaged artifacts` : 'Awaiting first artifact upload'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '24px', fontWeight: 800, color: C.bright }}>
|
||||
Release & Distribution Hub
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.2,
|
||||
py: 0.3,
|
||||
borderRadius: '6px',
|
||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||
color: C.cyanLight,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
v1.0.0 STABLE
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
Manage desktop installer distribution, multi-platform binaries, SHA-256 integrity checks, and auto-update feeds.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Button
|
||||
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
startIcon={<ExternalLink size={14} />}
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: C.text,
|
||||
borderColor: C.border,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { borderColor: C.accentLight, bgcolor: 'rgba(255, 255, 255, 0.05)' },
|
||||
}}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||
{cards.map((card) => (
|
||||
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Typography
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||
>
|
||||
Git Release Tags
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href="http://localhost:3000/download.html"
|
||||
target="_blank"
|
||||
variant="contained"
|
||||
startIcon={<ArrowUpRight size={14} />}
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
bgcolor: C.cyanLight,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { bgcolor: '#7dd3fc' },
|
||||
}}
|
||||
>
|
||||
View Public Download Page
|
||||
</Button>
|
||||
{card.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
|
||||
{card.value}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function LatestAssetsPanel({ latest }: { latest: ReleaseHub['latestStable'] }): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
|
||||
Latest Binary Packages & Checksums
|
||||
</Typography>
|
||||
{latest && (
|
||||
<Box sx={statusBadgeSx('cyan')}>{latest.tagName}</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Total App Downloads
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}>
|
||||
{totalDownloads.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: '#34d399', mt: 0.5 }}>
|
||||
+18.4% WoW (Free & Pro Installs)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Auto-Update Feed Health
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle2 size={20} color="#34d399" />
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 800, color: C.bright }}>
|
||||
200 OK (Live)
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}>
|
||||
latest.yml • generic feed
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Synology NAS Storage
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}>
|
||||
2.4 GB / 8 TB
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
/volume1/docker/d3ro-voice
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Active Release Version
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.purple400 }}>
|
||||
1.0.0
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
Deployed: 2026-08-20
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Phased Rollout & Control Card */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: '20px',
|
||||
bgcolor: 'rgba(17, 26, 48, 0.7)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: `1px solid ${C.border}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2, mb: 3 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
Phased Rollout & Auto-Update Policy
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
|
||||
Control automatic background update delivery to client desktop installations.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
|
||||
Force Update:
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={forceUpdateEnabled}
|
||||
onChange={(e) => setForceUpdateEnabled(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: '8px',
|
||||
bgcolor: 'rgba(34, 197, 94, 0.12)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
color: '#34d399',
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Rollout: {rolloutPercent}%
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={rolloutPercent}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: '4px',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: C.cyanLight },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{[10, 25, 50, 100].map((pct) => (
|
||||
<Button
|
||||
key={pct}
|
||||
size="small"
|
||||
onClick={() => setRolloutPercent(pct)}
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: rolloutPercent === pct ? '#000' : C.text,
|
||||
bgcolor: rolloutPercent === pct ? C.cyanLight : 'rgba(255, 255, 255, 0.04)',
|
||||
border: `1px solid ${rolloutPercent === pct ? C.cyanLight : C.border}`,
|
||||
borderRadius: '8px',
|
||||
textTransform: 'none',
|
||||
'&:hover': { bgcolor: rolloutPercent === pct ? '#7dd3fc' : 'rgba(255, 255, 255, 0.08)' },
|
||||
}}
|
||||
>
|
||||
Set {pct}%
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Release Assets Table */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
bgcolor: 'rgba(17, 26, 48, 0.7)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: `1px solid ${C.border}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2.5, borderBottom: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
Published Binary Packages & Checksums
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!latest || latest.assets.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
최신 릴리스에 업로드된 아티팩트가 없습니다.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ borderBottom: `1px solid ${C.border}`, bgcolor: 'rgba(0, 0, 0, 0.2)' }}>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Artifact Name</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Platform</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Version</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Size</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Downloads</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>SHA-256 Checksum</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Status</Box>
|
||||
<Box component="tr">
|
||||
<Box component="th">Artifact</Box>
|
||||
<Box component="th">Platform</Box>
|
||||
<Box component="th">Size</Box>
|
||||
<Box component="th">Downloads</Box>
|
||||
<Box component="th">SHA-256</Box>
|
||||
<Box component="th">Link</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box component="tbody">
|
||||
{assets.map((asset) => (
|
||||
<Box
|
||||
component="tr"
|
||||
key={asset.id}
|
||||
sx={{
|
||||
borderBottom: `1px solid ${C.border}`,
|
||||
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.02)' },
|
||||
}}
|
||||
>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 600, color: C.bright }}>
|
||||
{latest.assets.map((asset) => (
|
||||
<Box component="tr" key={asset.id}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{asset.name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
|
||||
{asset.os}
|
||||
<Box component="td">
|
||||
<Box sx={statusBadgeSx(PLATFORM_BADGE[asset.platform])}>{PLATFORM_LABEL[asset.platform]}</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{asset.version}
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatSize(asset.sizeBytes)}</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{asset.downloadCount.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
{asset.sizeMb} MB
|
||||
<Box component="td">
|
||||
{asset.sha256 ? (
|
||||
<ChecksumCopy value={asset.sha256} />
|
||||
) : (
|
||||
<Typography component="span" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.muted }}>
|
||||
not published
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.bright }}>
|
||||
{asset.downloads.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<span style={{ maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{asset.sha256}
|
||||
</span>
|
||||
<Box
|
||||
onClick={() => copyToClipboard(asset.sha256, asset.id)}
|
||||
sx={{ cursor: 'pointer', color: copiedId === asset.id ? '#34d399' : C.cyanLight, '&:hover': { color: '#fff' } }}
|
||||
>
|
||||
{copiedId === asset.id ? <CheckCircle2 size={13} /> : <Copy size={13} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
px: 1,
|
||||
py: 0.3,
|
||||
borderRadius: '6px',
|
||||
fontSize: '10px',
|
||||
fontFamily: FONT_MONO,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
bgcolor: asset.status === 'active' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(245, 158, 11, 0.15)',
|
||||
color: asset.status === 'active' ? '#34d399' : '#fbbf24',
|
||||
border: `1px solid ${asset.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(245, 158, 11, 0.3)'}`,
|
||||
}}
|
||||
<Box component="td">
|
||||
<Typography
|
||||
component="a"
|
||||
href={asset.browserDownloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
|
||||
>
|
||||
{asset.status}
|
||||
</Box>
|
||||
Download ↗
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryPanel({ hub }: { hub: ReleaseHub }): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||
Release Channel History
|
||||
</Typography>
|
||||
{hub.releases.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>발행된 릴리스가 없습니다.</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Tag</Box>
|
||||
<Box component="th">Release</Box>
|
||||
<Box component="th">Published</Box>
|
||||
<Box component="th">Channel</Box>
|
||||
<Box component="th">Assets</Box>
|
||||
<Box component="th">Downloads</Box>
|
||||
<Box component="th">Source</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{hub.releases.map((release) => (
|
||||
<Box component="tr" key={release.id}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{release.tagName}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
|
||||
{release.name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatDate(release.publishedAt)}</Box>
|
||||
<Box component="td">
|
||||
<Box sx={statusBadgeSx(release.isPrerelease ? 'orange' : 'green')}>
|
||||
{release.isPrerelease ? 'PRE-RELEASE' : 'STABLE'}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
{release.assets.length.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{release.downloadCount.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td">
|
||||
<Typography
|
||||
component="a"
|
||||
href={release.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
|
||||
>
|
||||
Forgejo ↗
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function ReleasesManagementPage(): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
|
||||
let hub: ReleaseHub | null = null
|
||||
let feedError: string | null = null
|
||||
try {
|
||||
hub = await fetchReleaseHub()
|
||||
} catch (error) {
|
||||
feedError = error instanceof Error ? error.message : 'Unknown release feed error'
|
||||
}
|
||||
|
||||
if (!hub) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<HeaderBar feedLive={false} repoHtmlUrl="https://git.chanpaca.net/yunchan/d3ro-voice" />
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
|
||||
Forgejo 릴리스 피드에 연결하지 못했습니다.
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{feedError}</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mt: 1.5 }}>
|
||||
네트워크 상태를 확인하거나 RELEASE_REPO_URL 환경변수를 점검해주세요. 샘플 수치는 표시하지 않습니다.
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<HeaderBar feedLive repoHtmlUrl={hub.repoHtmlUrl} />
|
||||
<KpiCards hub={hub} />
|
||||
<LatestAssetsPanel latest={hub.latestStable} />
|
||||
<ReleaseHistoryPanel hub={hub} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue