@@ -0,0 +1,854 @@
<!DOCTYPE html>
< html lang = "ko" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > luke_scribe 대시보드< / title >
< style >
: root {
--bg : #0b0f17 ;
--panel : #111827 ;
--panel-2 : #16203a ;
--border : #24304d ;
--text : #e6ecf7 ;
--muted : #8b98b5 ;
--accent : #6366f1 ;
--accent-2 : #22d3ee ;
--ok : #34d399 ;
--warn : #fbbf24 ;
--err : #f87171 ;
--mono : ui-monospace , "SF Mono" , "JetBrains Mono" , Consolas , monospace ;
}
* { box-sizing : border-box ; }
html , body { margin : 0 ; padding : 0 ; }
body {
background :
radial-gradient ( 1200 px 500 px at 80 % -10 % , rgba ( 99 , 102 , 241 , .14 ) , transparent 60 % ) ,
radial-gradient ( 900 px 400 px at -10 % 110 % , rgba ( 34 , 211 , 238 , .08 ) , transparent 60 % ) ,
var ( - - bg ) ;
color : var ( - - text ) ;
font-family : - apple-system , BlinkMacSystemFont , "Segoe UI" , "Noto Sans KR" , "Apple SD Gothic Neo" , sans-serif ;
font-size : 14 px ;
line-height : 1.55 ;
min-height : 100 vh ;
}
. wrap { max-width : 1080 px ; margin : 0 auto ; padding : 20 px 20 px 60 px ; }
/* ── 헤더 ── */
header {
display : flex ; align-items : center ; gap : 14 px ; flex-wrap : wrap ;
padding : 14 px 20 px ;
border-bottom : 1 px solid var ( - - border ) ;
background : rgba ( 17 , 24 , 39 , .7 ) ;
backdrop-filter : blur ( 8 px ) ;
position : sticky ; top : 0 ; z-index : 20 ;
}
. brand { font-size : 17 px ; font-weight : 700 ; letter-spacing : .3 px ; display : flex ; align-items : center ; gap : 8 px ; }
. brand . dot { width : 10 px ; height : 10 px ; border-radius : 50 % ; background : linear-gradient ( 135 deg , var ( - - accent ) , var ( - - accent -2 ) ) ; box-shadow : 0 0 12 px rgba ( 99 , 102 , 241 , .8 ) ; }
. brand small { color : var ( - - muted ) ; font-weight : 500 ; font-size : 12 px ; }
. spacer { flex : 1 ; }
# server-pill { display : inline-flex ; align-items : center ; gap : 6 px ; font-size : 12 px ; padding : 4 px 10 px ; border-radius : 999 px ; border : 1 px solid var ( - - border ) ; color : var ( - - muted ) ; }
# server-pill . l { width : 8 px ; height : 8 px ; border-radius : 50 % ; background : var ( - - muted ) ; }
# server-pill . ok { color : var ( - - ok ) ; border-color : rgba ( 52 , 211 , 153 , .4 ) ; }
# server-pill . ok . l { background : var ( - - ok ) ; box-shadow : 0 0 8 px rgba ( 52 , 211 , 153 , .7 ) ; }
# server-pill . err { color : var ( - - err ) ; border-color : rgba ( 248 , 113 , 113 , .4 ) ; }
# server-pill . err . l { background : var ( - - err ) ; }
. keybox { display : flex ; align-items : center ; gap : 6 px ; }
. keybox input {
background : var ( - - panel ) ; color : var ( - - text ) ; border : 1 px solid var ( - - border ) ;
border-radius : 8 px ; padding : 6 px 10 px ; font-family : var ( - - mono ) ; font-size : 12 px ; width : 220 px ;
}
. keybox input : focus { outline : none ; border-color : var ( - - accent ) ; }
# scope-badge { font-size : 11 px ; padding : 3 px 8 px ; border-radius : 6 px ; background : var ( - - panel -2 ) ; color : var ( - - muted ) ; border : 1 px solid var ( - - border ) ; white-space : nowrap ; }
# scope-badge . admin { color : var ( - - accent -2 ) ; border-color : rgba ( 34 , 211 , 238 , .4 ) ; }
/* ── 탭 ── */
nav . tabs { display : flex ; gap : 6 px ; margin : 20 px 0 14 px ; flex-wrap : wrap ; }
nav . tabs button {
background : transparent ; color : var ( - - muted ) ; border : 1 px solid transparent ;
padding : 8 px 16 px ; border-radius : 10 px ; cursor : pointer ; font-size : 13.5 px ; font-weight : 600 ;
transition : all .15 s ease ;
}
nav . tabs button : hover { color : var ( - - text ) ; background : rgba ( 99 , 102 , 241 , .08 ) ; }
nav . tabs button . active { color : #fff ; background : linear-gradient ( 135 deg , rgba ( 99 , 102 , 241 , .25 ) , rgba ( 34 , 211 , 238 , .15 ) ) ; border-color : rgba ( 99 , 102 , 241 , .5 ) ; }
section . tab { display : none ; animation : fade .25 s ease ; }
section . tab . active { display : block ; }
@ keyframes fade { from { opacity : 0 ; transform : translateY ( 4 px ) ; } to { opacity : 1 ; } }
/* ── 카드 / 패널 ── */
. card { background : var ( - - panel ) ; border : 1 px solid var ( - - border ) ; border-radius : 14 px ; padding : 18 px ; margin-bottom : 16 px ; }
. card h3 { margin : 0 0 14 px ; font-size : 14 px ; font-weight : 700 ; display : flex ; align-items : center ; gap : 8 px ; }
. card h3 . ico { width : 22 px ; height : 22 px ; display : grid ; place-items : center ; border-radius : 7 px ; background : linear-gradient ( 135 deg , rgba ( 99 , 102 , 241 , .25 ) , rgba ( 34 , 211 , 238 , .15 ) ) ; font-size : 13 px ; }
. grid { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 150 px , 1 fr ) ) ; gap : 10 px ; }
. stat { background : var ( - - panel -2 ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 12 px 14 px ; }
. stat . k { font-size : 11 px ; color : var ( - - muted ) ; text-transform : uppercase ; letter-spacing : .4 px ; }
. stat . v { font-size : 17 px ; font-weight : 700 ; margin-top : 3 px ; font-family : var ( - - mono ) ; }
. stat . v small { font-size : 12 px ; color : var ( - - muted ) ; font-weight : 500 ; }
/* ── 폼 ── */
label . f { display : block ; font-size : 12 px ; color : var ( - - muted ) ; margin : 10 px 0 5 px ; font-weight : 600 ; }
input [ type = text ] , input [ type = password ] , select , textarea {
width : 100 % ; background : var ( - - panel -2 ) ; color : var ( - - text ) ; border : 1 px solid var ( - - border ) ;
border-radius : 9 px ; padding : 8 px 11 px ; font-size : 13 px ; font-family : inherit ;
}
input : focus , select : focus , textarea : focus { outline : none ; border-color : var ( - - accent ) ; }
. row { display : flex ; gap : 12 px ; flex-wrap : wrap ; }
. row > * { flex : 1 1 180 px ; }
button . btn {
background : linear-gradient ( 135 deg , var ( - - accent ) , #4f46e5 ) ; color : #fff ; border : none ;
padding : 9 px 18 px ; border-radius : 10 px ; font-size : 13.5 px ; font-weight : 700 ; cursor : pointer ;
transition : filter .15 s ease , transform .1 s ease ; font-family : inherit ;
}
button . btn : hover { filter : brightness ( 1.15 ) ; }
button . btn : active { transform : scale ( .98 ) ; }
button . btn : disabled { opacity : .5 ; cursor : not-allowed ; }
button . ghost { background : transparent ; color : var ( - - muted ) ; border : 1 px solid var ( - - border ) ; }
button . ghost : hover { color : var ( - - text ) ; border-color : var ( - - accent ) ; }
button . mini { padding : 4 px 10 px ; font-size : 12 px ; border-radius : 7 px ; }
. drop {
border : 2 px dashed var ( - - border ) ; border-radius : 12 px ; padding : 26 px ; text-align : center ;
color : var ( - - muted ) ; cursor : pointer ; transition : all .15 s ease ; margin-bottom : 8 px ;
}
. drop : hover , . drop . over { border-color : var ( - - accent ) ; color : var ( - - text ) ; background : rgba ( 99 , 102 , 241 , .06 ) ; }
. drop . fname { color : var ( - - accent -2 ) ; font-family : var ( - - mono ) ; font-size : 12 px ; }
. chips { display : flex ; gap : 14 px ; flex-wrap : wrap ; margin-top : 10 px ; }
. chips label { display : inline-flex ; align-items : center ; gap : 6 px ; font-size : 12.5 px ; color : var ( - - muted ) ; cursor : pointer ; }
. chips input { accent-color : var ( - - accent ) ; }
/* ── 진행률 ── */
. progress { height : 8 px ; background : var ( - - panel -2 ) ; border-radius : 999 px ; overflow : hidden ; margin : 8 px 0 4 px ; }
. progress > i { display : block ; height : 100 % ; width : 0 ; border-radius : 999 px ; background : linear-gradient ( 90 deg , var ( - - accent ) , var ( - - accent -2 ) ) ; transition : width .4 s ease ; }
. progress . lg { height : 12 px ; }
. hint { font-size : 12 px ; color : var ( - - muted ) ; margin-top : 6 px ; }
/* ── 배지 / 테이블 ── */
. badge { display : inline-block ; font-size : 11 px ; font-weight : 700 ; padding : 3 px 9 px ; border-radius : 999 px ; }
. badge . queued { background : rgba ( 139 , 152 , 181 , .15 ) ; color : var ( - - muted ) ; }
. badge . processing { background : rgba ( 34 , 211 , 238 , .14 ) ; color : var ( - - accent -2 ) ; }
. badge . completed { background : rgba ( 52 , 211 , 153 , .14 ) ; color : var ( - - ok ) ; }
. badge . failed { background : rgba ( 248 , 113 , 113 , .14 ) ; color : var ( - - err ) ; }
. badge . cancelled { background : rgba ( 248 , 113 , 113 , .1 ) ; color : var ( - - muted ) ; }
table { width : 100 % ; border-collapse : collapse ; font-size : 12.5 px ; }
th { text-align : left ; color : var ( - - muted ) ; font-weight : 600 ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .4 px ; padding : 8 px 10 px ; border-bottom : 1 px solid var ( - - border ) ; }
td { padding : 9 px 10 px ; border-bottom : 1 px solid rgba ( 36 , 48 , 77 , .6 ) ; vertical-align : middle ; }
tr : hover td { background : rgba ( 99 , 102 , 241 , .04 ) ; }
. mono { font-family : var ( - - mono ) ; }
. muted { color : var ( - - muted ) ; }
. err { color : var ( - - err ) ; }
/* ── 결과 영역 ── */
. result-text {
background : var ( - - panel -2 ) ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ;
padding : 14 px ; font-size : 15 px ; line-height : 1.8 ; white-space : pre-wrap ; word-break : break-word ;
min-height : 70 px ; max-height : 300 px ; overflow : auto ;
}
. seg-table td : nth-child ( 2 ) , . seg-table td : nth-child ( 3 ) { font-family : var ( - - mono ) ; font-size : 11.5 px ; color : var ( - - muted ) ; white-space : nowrap ; }
. conf-low { color : var ( - - warn ) ; }
/* ── 실시간 ── */
# rt-status { font-size : 12.5 px ; color : var ( - - muted ) ; margin : 8 px 0 ; min-height : 18 px ; }
# rt-partial { color : var ( - - muted ) ; font-style : italic ; min-height : 24 px ; font-size : 14 px ; }
# rt-final { font-size : 15 px ; line-height : 1.8 ; white-space : pre-wrap ; word-break : break-word ; }
# rt-wave { display : flex ; align-items : center ; gap : 3 px ; height : 34 px ; margin : 10 px 0 ; }
# rt-wave i { width : 3 px ; background : var ( - - accent -2 ) ; border-radius : 2 px ; height : 6 px ; transition : height .12 s ease ; }
# rt-wave . live i { animation : wave 0.9 s ease-in-out infinite ; }
@ keyframes wave { 0 % , 100 % { height : 30 % ; } 50 % { height : 95 % ; } }
/* ── 모달 / 토스트 ── */
. modal-backdrop { position : fixed ; inset : 0 ; background : rgba ( 4 , 6 , 12 , .7 ) ; backdrop-filter : blur ( 3 px ) ; display : none ; align-items : flex-start ; justify-content : center ; padding : 40 px 16 px ; z-index : 50 ; overflow : auto ; }
. modal-backdrop . open { display : flex ; }
. modal { background : var ( - - panel ) ; border : 1 px solid var ( - - border ) ; border-radius : 16 px ; max-width : 760 px ; width : 100 % ; padding : 20 px ; }
. modal h3 { margin : 0 0 12 px ; }
. modal . close { float : right ; background : none ; border : none ; color : var ( - - muted ) ; font-size : 20 px ; cursor : pointer ; }
# toasts { position : fixed ; right : 16 px ; bottom : 16 px ; display : flex ; flex-direction : column ; gap : 8 px ; z-index : 100 ; }
. toast { background : var ( - - panel -2 ) ; border : 1 px solid var ( - - border ) ; border-left : 3 px solid var ( - - accent ) ; color : var ( - - text ) ; padding : 10 px 14 px ; border-radius : 10 px ; font-size : 13 px ; box-shadow : 0 6 px 24 px rgba ( 0 , 0 , 0 , .4 ) ; animation : slidein .2 s ease ; max-width : 380 px ; }
. toast . ok { border-left-color : var ( - - ok ) ; }
. toast . err { border-left-color : var ( - - err ) ; }
@ keyframes slidein { from { opacity : 0 ; transform : translateX ( 20 px ) ; } to { opacity : 1 ; } }
. raw-key {
background : #0d1320 ; border : 1 px dashed var ( - - accent ) ; border-radius : 10 px ;
padding : 12 px ; font-family : var ( - - mono ) ; font-size : 12.5 px ; word-break : break-all ; margin : 10 px 0 ;
color : var ( - - accent -2 ) ;
}
footer { margin-top : 40 px ; text-align : center ; color : var ( - - muted ) ; font-size : 12 px ; }
@ media ( max-width : 640px ) { . keybox input { width : 150 px ; } }
< / style >
< / head >
< body >
< header >
< div class = "brand" > < span class = "dot" > < / span > luke_scribe < small > v0.1 · 대시보드< / small > < / div >
< div class = "spacer" > < / div >
< span id = "server-pill" > < span class = "l" > < / span > < span id = "server-pill-txt" > 연결 확인 중…< / span > < / span >
< span id = "scope-badge" hidden > < / span >
< div class = "keybox" >
< input id = "api-key" type = "password" placeholder = "API 키 (X-API-Key)" autocomplete = "off" >
< button class = "btn mini" id = "save-key" > 저장< / button >
< / div >
< / header >
< div class = "wrap" >
< nav class = "tabs" >
< button data-tab = "system" class = "active" > 🖥️ 시스템< / button >
< button data-tab = "upload" > 🎙️ 전사< / button >
< button data-tab = "jobs" > 📋 작업< / button >
< button data-tab = "realtime" > ⚡ 실시간< / button >
< button data-tab = "keys" > 🔑 API 키< / button >
< / nav >
<!-- ── 시스템 ── -->
< section id = "tab-system" class = "tab active" >
< div class = "card" >
< h3 > < span class = "ico" > 🖥️< / span > 시스템 상태< / h3 >
< div class = "grid" id = "sys-grid" >
< div class = "stat" > < div class = "k" > 연결 상태< / div > < div class = "v" id = "s-status" > —< / div > < / div >
< div class = "stat" > < div class = "k" > 능력 등급< / div > < div class = "v" id = "s-tier" > —< / div > < / div >
< div class = "stat" > < div class = "k" > GPU< / div > < div class = "v" id = "s-gpu" > < small > —< / small > < / div > < / div >
< div class = "stat" > < div class = "k" > VRAM< / div > < div class = "v" id = "s-vram" > —< / div > < / div >
< div class = "stat" > < div class = "k" > 컴퓨트 타입< / div > < div class = "v" id = "s-ct" > —< / div > < / div >
< div class = "stat" > < div class = "k" > 워커< / div > < div class = "v" id = "s-workers" > —< / div > < / div >
< div class = "stat" > < div class = "k" > 큐 깊이< / div > < div class = "v" id = "s-queue" > —< / div > < / div >
< div class = "stat" > < div class = "k" > 모델< / div > < div class = "v" id = "s-model" > < small > —< / small > < / div > < / div >
< / div >
< p class = "hint" id = "sys-hint" > < / p >
< / div >
< div class = "card" >
< h3 > < span class = "ico" > 📊< / span > 장치 상세< / h3 >
< pre class = "mono muted" id = "sys-detail" style = "font-size:12px; margin:0; white-space:pre-wrap;" > < / pre >
< / div >
< / section >
<!-- ── 전사 ── -->
< section id = "tab-upload" class = "tab" >
< div class = "card" >
< h3 > < span class = "ico" > 🎙️< / span > 파일 전사< / h3 >
< div class = "drop" id = "drop" >
< div > 🎧 파일을 끌어다 놓거나 클릭하여 선택< / div >
< div class = "hint" id = "drop-name" > < / div >
< / div >
< input type = "file" id = "file-input" hidden >
< div class = "row" >
< div >
< label class = "f" > 언어< / label >
< select id = "opt-language" >
< option value = "ko" selected > ko (한국어)< / option >
< option value = "auto" > auto (자동 감지)< / option >
< option value = "en" > en (영어)< / option >
< option value = "ja" > ja (일본어)< / option >
< / select >
< / div >
< div >
< label class = "f" > 모델< / label >
< select id = "opt-model" >
< option value = "large-v3-turbo" selected > large-v3-turbo (빠름)< / option >
< option value = "large-v3" > large-v3 (정확)< / option >
< / select >
< / div >
< div >
< label class = "f" > 컴퓨트 타입< / label >
< select id = "opt-ct" >
< option value = "auto" selected > auto< / option >
< option value = "float16" > float16< / option >
< option value = "int8_float16" > int8_float16< / option >
< option value = "int8" > int8< / option >
< / select >
< / div >
< div >
< label class = "f" > 장치< / label >
< select id = "opt-device" >
< option value = "auto" selected > auto< / option >
< option value = "cpu" > cpu< / option >
< option value = "cuda" > cuda< / option >
< / select >
< / div >
< / div >
< div class = "row" >
< div >
< label class = "f" > Hotwords (콤마 구분)< / label >
< input type = "text" id = "opt-hotwords" placeholder = "vLLM, Kubernetes" >
< / div >
< div >
< label class = "f" > Glossary (KEY=VALUE, 콤마 구분)< / label >
< input type = "text" id = "opt-glossary" placeholder = "BLM=vLLM" >
< / div >
< / div >
< div class = "chips" >
< label > < input type = "checkbox" value = "json" checked > JSON< / label >
< label > < input type = "checkbox" value = "txt" > TXT< / label >
< label > < input type = "checkbox" value = "srt" > SRT< / label >
< label > < input type = "checkbox" value = "vtt" > VTT< / label >
< / div >
< div style = "margin-top:14px;" >
< button class = "btn" id = "btn-upload" > 전사 시작< / button >
< span class = "hint" id = "upload-hint" > < / span >
< / div >
< div id = "upload-progress-wrap" hidden >
< div class = "progress lg" > < i id = "upload-progress-bar" > < / i > < / div >
< div class = "hint" id = "upload-progress-txt" > < / div >
< / div >
< / div >
< div class = "card" id = "result-card" hidden >
< h3 > < span class = "ico" > 📄< / span > 전사 결과 < span id = "result-meta" class = "muted" style = "font-size:12px; font-weight:500;" > < / span > < / h3 >
< div style = "display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;" id = "result-downloads" > < / div >
< div class = "result-text" id = "result-text" > < / div >
< div class = "hint" id = "result-warnings" > < / div >
< h3 style = "margin-top:18px;" > < span class = "ico" > ⏱️< / span > 세그먼트< / h3 >
< div style = "max-height:300px; overflow:auto;" >
< table class = "seg-table" >
< thead > < tr > < th > #< / th > < th > 시작< / th > < th > 끝< / th > < th > 텍스트< / th > < th > 신뢰도< / th > < / tr > < / thead >
< tbody id = "result-segments" > < / tbody >
< / table >
< / div >
< / div >
< / section >
<!-- ── 작업 ── -->
< section id = "tab-jobs" class = "tab" >
< div class = "card" >
< h3 > < span class = "ico" > 📋< / span > 작업 히스토리 < span class = "muted" style = "font-weight:500; font-size:12px;" > 3초 자동 갱신< / span > < / h3 >
< div style = "max-height:520px; overflow:auto;" >
< table >
< thead > < tr > < th > 상태< / th > < th > 파일< / th > < th > job id< / th > < th > 진행률< / th > < th > 작업< / th > < / tr > < / thead >
< tbody id = "jobs-tbody" > < tr > < td colspan = "5" class = "muted" > —< / td > < / tr > < / tbody >
< / table >
< / div >
< / div >
< / section >
<!-- ── 실시간 ── -->
< section id = "tab-realtime" class = "tab" >
< div class = "card" >
< h3 > < span class = "ico" > ⚡< / span > 실시간 마이크 전사 (WebSocket)< / h3 >
< p class = "hint" style = "margin-top:0;" > 마이크 오디오를 16kHz PCM으로 보내 서버가 실시간 가설/확정 텍스트를 반환합니다. 첫 가설은 모델 로드로 지연될 수 있습니다.< / p >
< button class = "btn" id = "rt-start" > 🎤 녹음 시작< / button >
< button class = "btn ghost" id = "rt-stop" disabled > ⏹ 정지< / button >
< div id = "rt-status" > < / div >
< div id = "rt-wave" > < / div >
< div class = "hint" id = "rt-partial" > < / div >
< div id = "rt-final" > < / div >
< / div >
< / section >
<!-- ── API 키 ── -->
< section id = "tab-keys" class = "tab" >
< div class = "card" >
< h3 > < span class = "ico" > 🔑< / span > 키 생성 (admin 스코프 필요)< / h3 >
< div class = "chips" >
< label > < input type = "checkbox" value = "transcribe" checked > transcribe< / label >
< label > < input type = "checkbox" value = "admin" > admin< / label >
< / div >
< div style = "margin-top:12px;" > < button class = "btn" id = "btn-create-key" > 새 키 생성< / button > < / div >
< div id = "new-key-area" hidden >
< p class = "hint" style = "margin:12px 0 2px;" > ✅ 생성됨 — < b class = "err" > raw 키는 이번 한 번만 표시됩니다.< / b > 복사해서 안전한 곳에 보관하세요.< / p >
< div class = "raw-key" id = "new-key" > < / div >
< button class = "btn mini" id = "btn-copy-key" > 📋 복사< / button >
< / div >
< / div >
< div class = "card" >
< h3 > < span class = "ico" > 🗝️< / span > 키 목록< / h3 >
< table >
< thead > < tr > < th > key id< / th > < th > 스코프< / th > < / tr > < / thead >
< tbody id = "keys-tbody" > < tr > < td colspan = "2" class = "muted" > —< / td > < / tr > < / tbody >
< / table >
< / div >
< / section >
< footer > privacy-first · 모든 처리는 로컬/자체 서버에서 수행됩니다< / footer >
< / div >
< div class = "modal-backdrop" id = "result-modal" >
< div class = "modal" >
< button class = "close" id = "modal-close" > × < / button >
< h3 id = "modal-title" > 결과< / h3 >
< div style = "display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;" id = "modal-downloads" > < / div >
< div class = "result-text" id = "modal-text" > < / div >
< div class = "hint" id = "modal-warnings" > < / div >
< h3 style = "margin-top:16px;" > 세그먼트< / h3 >
< div style = "max-height:260px; overflow:auto;" >
< table class = "seg-table" >
< thead > < tr > < th > #< / th > < th > 시작< / th > < th > 끝< / th > < th > 텍스트< / th > < th > 신뢰도< / th > < / tr > < / thead >
< tbody id = "modal-segments" > < / tbody >
< / table >
< / div >
< / div >
< / div >
< div id = "toasts" > < / div >
< script >
"use strict" ;
/* ═══ 유틸 ═══ */
const $ = ( s ) => document . querySelector ( s ) ;
const LS _KEY = "luke_dash_key" ;
let apiKey = localStorage . getItem ( LS _KEY ) || "" ;
let scope = null ; // null | "none" | "transcribe" | "admin"
let uploadFile = null ;
let jobsTimer = null ;
let rt = null ; // 실시간 세션 객체
$ ( "#api-key" ) . value = apiKey ;
function toast ( msg , type = "" ) {
const el = document . createElement ( "div" ) ;
el . className = "toast " + type ;
el . textContent = msg ;
$ ( "#toasts" ) . appendChild ( el ) ;
setTimeout ( ( ) => el . remove ( ) , 4200 ) ;
}
function esc ( s ) {
return String ( s ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
}
function fmtTime ( sec ) {
if ( sec == null || isNaN ( sec ) ) return "—" ;
const m = Math . floor ( sec / 60 ) , s = Math . floor ( sec % 60 ) ;
return ` ${ String ( m ) . padStart ( 2 , "0" ) } : ${ String ( s ) . padStart ( 2 , "0" ) } ` ;
}
function fmtBytes ( n ) {
if ( n == null ) return "—" ;
if ( n < 1024 ) return n + " B" ;
if ( n < 1048576 ) return ( n / 1024 ) . toFixed ( 1 ) + " KB" ;
return ( n / 1048576 ) . toFixed ( 1 ) + " MB" ;
}
function confidence ( seg ) {
// avg_logprob → 0~1 신뢰도 추정
if ( seg . avg _logprob == null ) return null ;
return Math . max ( 0 , Math . min ( 1 , 1 + seg . avg _logprob ) ) ;
}
/* ═══ API 헬퍼 ═══ */
async function api ( path , opts = { } ) {
const headers = Object . assign ( { } , opts . headers || { } ) ;
if ( apiKey ) headers [ "X-API-Key" ] = apiKey ;
const res = await fetch ( path , Object . assign ( { } , opts , { headers } ) ) ;
if ( res . status === 401 ) {
scope = "none" ;
setScopeBadge ( ) ;
throw new Error ( "API 키 인증 실패 — 키를 확인하세요" ) ;
}
return res ;
}
async function apiJson ( path , opts = { } ) {
const res = await api ( path , opts ) ;
const ct = res . headers . get ( "content-type" ) || "" ;
if ( ! res . ok ) {
let msg = res . status + " " + res . statusText ;
try { const b = await res . json ( ) ; msg = b . detail || b . message || msg ; } catch ( e ) { }
throw new Error ( msg ) ;
}
if ( ct . includes ( "json" ) ) return res . json ( ) ;
return res . text ( ) ;
}
/* ═══ 키 저장 / 스코프 ═══ */
$ ( "#save-key" ) . addEventListener ( "click" , ( ) => {
apiKey = $ ( "#api-key" ) . value . trim ( ) ;
localStorage . setItem ( LS _KEY , apiKey ) ;
probeScope ( ) ;
toast ( apiKey ? "API 키 저장됨" : "API 키 제거됨" , "ok" ) ;
} ) ;
$ ( "#api-key" ) . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Enter" ) $ ( "#save-key" ) . click ( ) ; } ) ;
function setScopeBadge ( ) {
const b = $ ( "#scope-badge" ) ;
if ( ! scope || scope === "none" ) { b . hidden = true ; return ; }
b . hidden = false ;
b . textContent = scope === "admin" ? "admin" : "transcribe" ;
b . className = scope === "admin" ? "admin" : "" ;
}
async function probeScope ( ) {
if ( ! apiKey ) { scope = "none" ; setScopeBadge ( ) ; return ; }
try {
const res = await api ( "/v1/system" ) ;
if ( res . status === 200 ) { scope = "admin" ; }
else if ( res . status === 403 ) { scope = "transcribe" ; }
else { scope = "none" ; }
} catch ( e ) { scope = "none" ; }
setScopeBadge ( ) ;
}
/* ═══ 서버 상태 ═══ */
async function refreshHealth ( ) {
const pill = $ ( "#server-pill" ) , txt = $ ( "#server-pill-txt" ) ;
try {
const res = await fetch ( "/health" ) ;
if ( res . ok ) {
const b = await res . json ( ) ;
pill . className = "ok" ;
txt . textContent = "서버 정상 · 큐 " + b . queue _depth + ( b . model _ready ? " · 모델 준비됨" : "" ) ;
} else { pill . className = "err" ; txt . textContent = "서버 응답 이상 (" + res . status + ")" ; }
} catch ( e ) { pill . className = "err" ; txt . textContent = "서버 오프라인" ; }
}
/* ═══ 탭 ═══ */
document . querySelectorAll ( "nav.tabs button" ) . forEach ( ( btn ) => {
btn . addEventListener ( "click" , ( ) => {
document . querySelectorAll ( "nav.tabs button" ) . forEach ( ( b ) => b . classList . remove ( "active" ) ) ;
document . querySelectorAll ( "section.tab" ) . forEach ( ( s ) => s . classList . remove ( "active" ) ) ;
btn . classList . add ( "active" ) ;
const tab = btn . dataset . tab ;
$ ( "#tab-" + tab ) . classList . add ( "active" ) ;
if ( tab === "system" ) loadSystem ( ) ;
if ( tab === "jobs" ) { loadJobs ( ) ; startJobsTimer ( ) ; }
if ( tab === "keys" ) loadKeys ( ) ;
} ) ;
} ) ;
/* ═══ 시스템 ═══ */
async function loadSystem ( ) {
$ ( "#sys-hint" ) . textContent = "" ;
try {
const res = await api ( "/v1/system" ) ;
if ( res . status === 403 ) { $ ( "#sys-hint" ) . textContent = "⚠ 시스템 정보는 admin 스코프 키가 필요합니다." ; return ; }
if ( ! res . ok ) { $ ( "#sys-hint" ) . textContent = "시스템 정보 로드 실패: " + res . status ; return ; }
const b = await res . json ( ) ;
const d = b . device || { } ;
$ ( "#s-status" ) . textContent = "정상" ;
$ ( "#s-tier" ) . textContent = d . capability _tier || b . capability _tier || "—" ;
$ ( "#s-gpu" ) . innerHTML = d . device _name ? esc ( d . device _name ) : "<small>—</small>" ;
$ ( "#s-vram" ) . innerHTML = d . vram _total _mb ? ( d . vram _total _mb / 1024 ) . toFixed ( 0 ) + " GB" : "—" ;
$ ( "#s-ct" ) . textContent = b . compute _type _used || "—" ;
$ ( "#s-workers" ) . textContent = b . workers ? ? "—" ;
$ ( "#s-queue" ) . textContent = b . queue _depth ? ? "—" ;
$ ( "#s-model" ) . innerHTML = ( b . models || [ ] ) . join ( "<br>" ) ;
$ ( "#sys-detail" ) . textContent = JSON . stringify ( b , null , 2 ) ;
} catch ( e ) {
$ ( "#sys-hint" ) . textContent = "⚠ " + e . message ;
}
}
/* ═══ 업로드 / 전사 ═══ */
const drop = $ ( "#drop" ) , fileInput = $ ( "#file-input" ) ;
drop . addEventListener ( "click" , ( ) => fileInput . click ( ) ) ;
drop . addEventListener ( "dragover" , ( e ) => { e . preventDefault ( ) ; drop . classList . add ( "over" ) ; } ) ;
drop . addEventListener ( "dragleave" , ( ) => drop . classList . remove ( "over" ) ) ;
drop . addEventListener ( "drop" , ( e ) => {
e . preventDefault ( ) ; drop . classList . remove ( "over" ) ;
if ( e . dataTransfer . files . length ) setFile ( e . dataTransfer . files [ 0 ] ) ;
} ) ;
fileInput . addEventListener ( "change" , ( ) => { if ( fileInput . files . length ) setFile ( fileInput . files [ 0 ] ) ; } ) ;
function setFile ( f ) {
uploadFile = f ;
$ ( "#drop-name" ) . textContent = "선택: " + f . name + " (" + fmtBytes ( f . size ) + ")" ;
}
$ ( "#btn-upload" ) . addEventListener ( "click" , async ( ) => {
if ( ! uploadFile ) { toast ( "파일을 먼저 선택하세요" , "err" ) ; return ; }
if ( ! apiKey ) { toast ( "API 키를 먼저 저장하세요 (상단)" , "err" ) ; return ; }
const formats = Array . from ( document . querySelectorAll ( "#tab-upload .chips input:checked" ) ) . map ( ( c ) => c . value ) ;
const glossary = { } ;
( $ ( "#opt-glossary" ) . value || "" ) . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) . forEach ( ( kv ) => {
const i = kv . indexOf ( "=" ) ;
if ( i > 0 ) glossary [ kv . slice ( 0 , i ) . trim ( ) ] = kv . slice ( i + 1 ) . trim ( ) ;
} ) ;
const options = {
language : $ ( "#opt-language" ) . value === "auto" ? null : $ ( "#opt-language" ) . value ,
model : $ ( "#opt-model" ) . value ,
compute _type : $ ( "#opt-ct" ) . value ,
device : $ ( "#opt-device" ) . value ,
hotwords : ( $ ( "#opt-hotwords" ) . value || "" ) . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ,
formats ,
} ;
if ( Object . keys ( glossary ) . length ) options . glossary = glossary ;
const fd = new FormData ( ) ;
fd . append ( "file" , uploadFile ) ;
fd . append ( "options" , JSON . stringify ( options ) ) ;
const btn = $ ( "#btn-upload" ) ;
btn . disabled = true ;
$ ( "#upload-hint" ) . textContent = "업로드 중…" ;
try {
const job = await apiJson ( "/v1/jobs" , { method : "POST" , body : fd } ) ;
$ ( "#upload-hint" ) . textContent = "job " + job . job _id + " — 처리 대기" ;
$ ( "#upload-progress-wrap" ) . hidden = false ;
await pollJob ( job . job _id ) ;
} catch ( e ) {
toast ( "업로드 실패: " + e . message , "err" ) ;
$ ( "#upload-hint" ) . textContent = "⚠ " + e . message ;
} finally {
btn . disabled = false ;
}
} ) ;
async function pollJob ( jobId ) {
for ( let i = 0 ; i < 600 ; i ++ ) {
await new Promise ( ( r ) => setTimeout ( r , 1500 ) ) ;
let st ;
try { st = await apiJson ( "/v1/jobs/" + jobId ) ; }
catch ( e ) { continue ; }
const pct = st . progress != null ? Math . round ( st . progress * 100 ) : ( st . status === "processing" ? 5 : 0 ) ;
$ ( "#upload-progress-bar" ) . style . width = pct + "%" ;
$ ( "#upload-progress-txt" ) . textContent = "상태: " + st . status + ( st . progress != null ? " · " + pct + "%" : "" ) ;
if ( st . status === "completed" ) {
$ ( "#upload-progress-txt" ) . textContent = "완료 ✅" ;
await loadResult ( jobId ) ;
toast ( "전사 완료" , "ok" ) ;
return ;
}
if ( st . status === "failed" || st . status === "cancelled" ) {
$ ( "#upload-progress-txt" ) . textContent = "상태: " + st . status + ( st . error && st . error . message ? " — " + st . error . message : "" ) ;
toast ( "전사 " + st . status , "err" ) ;
return ;
}
}
$ ( "#upload-progress-txt" ) . textContent = "폴링 시간 초과 — 작업 탭에서 확인하세요." ;
}
async function loadResult ( jobId , into = "main" ) {
const data = await apiJson ( "/v1/jobs/" + jobId + "/result?format=json" ) ;
const parsed = typeof data === "string" ? JSON . parse ( data ) : data ;
const card = into === "modal" ? "#modal-" : "#result-" ;
renderResult ( parsed , card , into , jobId ) ;
}
function renderResult ( body , card , into , jobId ) {
const textEl = $ ( card + "text" ) ;
const segEl = $ ( card + "segments" ) ;
const warnEl = $ ( card + "warnings" ) ;
const dlEl = into === "modal" ? $ ( "#modal-downloads" ) : $ ( "#result-downloads" ) ;
textEl . textContent = body . text || "(빈 결과)" ;
warnEl . textContent = ( body . warnings || [ ] ) . join ( "\n" ) ;
segEl . innerHTML = ( body . segments || [ ] ) . map ( ( s ) => {
const conf = confidence ( s ) ;
const confTxt = conf == null ? "—" : Math . round ( conf * 100 ) + "%" ;
return "<tr><td>" + s . index + '</td><td>' + fmtTime ( s . start ) + "</td><td>" + fmtTime ( s . end ) +
"</td><td>" + esc ( s . text ) + "</td><td class=\"" + ( conf != null && conf < 0.4 ? "conf-low" : "" ) + "\">" + confTxt + "</td></tr>" ;
} ) . join ( "" ) ;
const meta = $ ( card + "meta" ) ;
if ( body . execution && meta ) {
meta . textContent = ( body . execution . model || "" ) + " · " + ( body . execution . device || "" ) + " · " +
( body . execution . compute _type || "" ) + " · RTF " + ( body . timings && body . timings . rtf != null ? body . timings . rtf : "—" ) ;
}
dlEl . innerHTML = "" ;
[ "json" , "txt" , "srt" , "vtt" ] . forEach ( ( fmt ) => {
const b = document . createElement ( "button" ) ;
b . className = "btn mini ghost" ;
b . textContent = "⬇ " + fmt . toUpperCase ( ) ;
b . onclick = ( ) => downloadResult ( jobId , fmt ) ;
dlEl . appendChild ( b ) ;
} ) ;
if ( into === "main" ) $ ( "#result-card" ) . hidden = false ;
}
async function downloadResult ( jobId , fmt ) {
try {
const res = await api ( "/v1/jobs/" + jobId + "/result?format=" + fmt ) ;
const text = await res . text ( ) ;
const blob = new Blob ( [ text ] , { type : "text/plain;charset=utf-8" } ) ;
const a = document . createElement ( "a" ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = jobId . slice ( 0 , 8 ) + "." + fmt ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
} catch ( e ) { toast ( "다운로드 실패: " + e . message , "err" ) ; }
}
/* ═══ 작업 히스토리 ═══ */
async function loadJobs ( ) {
if ( ! apiKey ) { $ ( "#jobs-tbody" ) . innerHTML = '<tr><td colspan="5" class="muted">API 키를 저장하면 작업이 표시됩니다.</td></tr>' ; return ; }
let jobs = [ ] ;
try { jobs = await apiJson ( "/v1/jobs" ) ; }
catch ( e ) {
if ( ! jobsTimer ) startJobsTimer ( ) ;
$ ( "#jobs-tbody" ) . innerHTML = '<tr><td colspan="5" class="err">' + esc ( e . message ) + "</td></tr>" ;
return ;
}
if ( ! jobs . length ) { $ ( "#jobs-tbody" ) . innerHTML = '<tr><td colspan="5" class="muted">작업 없음</td></tr>' ; return ; }
$ ( "#jobs-tbody" ) . innerHTML = jobs . map ( ( j ) => {
const pct = j . progress != null ? Math . round ( j . progress * 100 ) : ( j . status === "processing" ? 5 : 0 ) ;
return "<tr>" +
'<td><span class="badge ' + j . status + '">' + j . status + "</span></td>" +
"<td>" + esc ( j . error && j . error . message ? "⚠ " + j . error . message : ( j . source _name || j . job _id ) ) + "</td>" +
'<td class="mono">' + j . job _id . slice ( 0 , 8 ) + "</td>" +
'<td style="min-width:110px;"><div class="progress"><i style="width:' + pct + '%"></i></div></td>' +
"<td>" +
'<button class="btn mini ghost" data-act="view" data-id="' + j . job _id + '">결과</button> ' +
( j . status === "queued" || j . status === "processing"
? '<button class="btn mini ghost" data-act="cancel" data-id="' + j . job _id + '">취소</button>'
: "" ) +
"</td></tr>" ;
} ) . join ( "" ) ;
$ ( "#jobs-tbody" ) . querySelectorAll ( "button[data-act]" ) . forEach ( ( b ) => {
b . addEventListener ( "click" , async ( ) => {
if ( b . dataset . act === "view" ) {
if ( ! jobs . find ( ( j ) => j . job _id === b . dataset . id && j . result _available ) ) {
toast ( "결과가 아직 준비되지 않았습니다" ) ;
return ;
}
openResultModal ( b . dataset . id ) ;
} else if ( b . dataset . act === "cancel" ) {
try { await api ( "/v1/jobs/" + b . dataset . id , { method : "DELETE" } ) ; toast ( "취소 요청됨" , "ok" ) ; }
catch ( e ) { toast ( "취소 실패: " + e . message , "err" ) ; }
}
} ) ;
} ) ;
}
function startJobsTimer ( ) {
if ( jobsTimer ) return ;
jobsTimer = setInterval ( ( ) => { if ( $ ( "#tab-jobs" ) . classList . contains ( "active" ) ) loadJobs ( ) ; } , 3000 ) ;
}
/* ── 결과 모달 ── */
async function openResultModal ( jobId ) {
$ ( "#modal-title" ) . textContent = "결과 — " + jobId . slice ( 0 , 8 ) ;
$ ( "#result-modal" ) . classList . add ( "open" ) ;
try {
const data = await apiJson ( "/v1/jobs/" + jobId + "/result?format=json" ) ;
const parsed = typeof data === "string" ? JSON . parse ( data ) : data ;
renderResult ( parsed , "#modal-" , "modal" , jobId ) ;
} catch ( e ) {
$ ( "#modal-text" ) . textContent = "결과 로드 실패: " + e . message ;
}
}
$ ( "#modal-close" ) . addEventListener ( "click" , ( ) => $ ( "#result-modal" ) . classList . remove ( "open" ) ) ;
$ ( "#result-modal" ) . addEventListener ( "click" , ( e ) => { if ( e . target . id === "result-modal" ) $ ( "#result-modal" ) . classList . remove ( "open" ) ; } ) ;
/* ═══ 실시간 ═══ */
function initWave ( ) {
const wave = $ ( "#rt-wave" ) ;
wave . innerHTML = "" ;
for ( let i = 0 ; i < 28 ; i ++ ) { const bar = document . createElement ( "i" ) ; wave . appendChild ( bar ) ; }
}
function waveLevel ( v ) {
const bars = $ ( "#rt-wave" ) . querySelectorAll ( "i" ) ;
bars . forEach ( ( b , i ) => { b . style . height = Math . min ( 100 , v * 140 + ( i % 3 ) * 6 ) + "%" ; } ) ;
}
function downsample ( buf , from , to ) {
if ( from === to ) return buf ;
const ratio = from / to ;
const out = new Float32Array ( Math . floor ( buf . length / ratio ) ) ;
for ( let i = 0 ; i < out . length ; i ++ ) out [ i ] = buf [ Math . floor ( i * ratio ) ] ;
return out ;
}
function toPCM16 ( f32 ) {
const out = new Int16Array ( f32 . length ) ;
for ( let i = 0 ; i < f32 . length ; i ++ ) out [ i ] = Math . max ( - 1 , Math . min ( 1 , f32 [ i ] ) ) * 32767 ;
return out . buffer ;
}
$ ( "#rt-start" ) . addEventListener ( "click" , async ( ) => {
if ( ! apiKey ) { toast ( "API 키를 먼저 저장하세요 (상단)" , "err" ) ; return ; }
if ( rt && rt . ws ) { toast ( "이미 녹음 중" ) ; return ; }
initWave ( ) ;
$ ( "#rt-final" ) . textContent = "" ;
$ ( "#rt-partial" ) . textContent = "" ;
$ ( "#rt-start" ) . disabled = true ;
$ ( "#rt-stop" ) . disabled = false ;
$ ( "#rt-wave" ) . classList . add ( "live" ) ;
const proto = location . protocol === "https:" ? "wss:" : "ws:" ;
const ws = new WebSocket ( proto + "//" + location . host + "/v1/stream" ) ;
rt = { ws , ctx : null , stream : null , stopped : false } ;
ws . onopen = ( ) => {
ws . send ( JSON . stringify ( { type : "init" , api _key : apiKey , audio : { sample _rate : 16000 } } ) ) ;
setRtStatus ( "연결됨 — 말씀해 주세요 🎙️" , "" ) ;
} ;
ws . onmessage = ( e ) => {
let m ; try { m = JSON . parse ( e . data ) ; } catch ( err ) { return ; }
if ( m . type === "status" ) {
if ( m . status === "ready" ) setRtStatus ( "세션 준비됨 — 녹음 중…" , "ok" ) ;
else if ( m . status === "error" ) setRtStatus ( "서버 오류: " + ( m . message || "" ) , "err" ) ;
else if ( m . status === "idle_timeout" ) setRtStatus ( "유휴 타임아웃" , "" ) ;
} else if ( m . type === "partial" ) {
$ ( "#rt-partial" ) . textContent = "⋯ " + ( m . text || "" ) ;
} else if ( m . type === "final" ) {
const seg = m . segment || { } ;
$ ( "#rt-partial" ) . textContent = "" ;
$ ( "#rt-final" ) . textContent += ( seg . text || "" ) + " " ;
}
} ;
ws . onclose = ( ) => {
if ( rt && ! rt . stopped ) setRtStatus ( "연결 종료" , "err" ) ;
$ ( "#rt-wave" ) . classList . remove ( "live" ) ;
} ;
ws . onerror = ( ) => setRtStatus ( "WebSocket 오류" , "err" ) ;
// 오디오 캡처 시작 (ws.open 후 init 전송 — 사용자 제스처 컨텍스트 유지)
try {
const ms = await navigator . mediaDevices . getUserMedia ( { audio : { echoCancellation : true , noiseSuppression : true } } ) ;
rt . stream = ms ;
const ctx = new AudioContext ( ) ;
rt . ctx = ctx ;
const src = ctx . createMediaStreamSource ( ms ) ;
const proc = ctx . createScriptProcessor ( 4096 , 1 , 1 ) ;
proc . onaudioprocess = ( e ) => {
if ( rt . stopped ) return ;
const ch = e . inputBuffer . getChannelData ( 0 ) ;
let amp = 0 ;
for ( let i = 0 ; i < ch . length ; i += 32 ) amp = Math . max ( amp , Math . abs ( ch [ i ] ) ) ;
waveLevel ( amp ) ;
const d = downsample ( ch , ctx . sampleRate , 16000 ) ;
if ( ws . readyState === WebSocket . OPEN ) ws . send ( toPCM16 ( d ) ) ;
} ;
src . connect ( proc ) ;
proc . connect ( ctx . destination ) ;
} catch ( e ) {
setRtStatus ( "오디오 캡처 실패: " + e . message , "err" ) ;
stopRt ( ) ;
}
} ) ;
function setRtStatus ( txt , cls ) {
const el = $ ( "#rt-status" ) ;
el . textContent = txt ;
el . className = cls === "err" ? "err" : ( cls === "ok" ? "ok" : "" ) ;
if ( cls === "ok" ) el . style . color = "var(--ok)" ;
else if ( cls === "err" ) el . style . color = "var(--err)" ;
else el . style . color = "var(--muted)" ;
}
function stopRt ( ) {
if ( ! rt ) return ;
rt . stopped = true ;
if ( rt . ctx ) rt . ctx . close ( ) . catch ( ( ) => { } ) ;
if ( rt . stream ) rt . stream . getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
if ( rt . ws && rt . ws . readyState <= WebSocket . OPEN ) rt . ws . close ( ) ;
rt = null ;
$ ( "#rt-start" ) . disabled = false ;
$ ( "#rt-stop" ) . disabled = true ;
$ ( "#rt-wave" ) . classList . remove ( "live" ) ;
waveLevel ( 0 ) ;
}
$ ( "#rt-stop" ) . addEventListener ( "click" , ( ) => { stopRt ( ) ; setRtStatus ( "녹음 중지됨" , "" ) ; } ) ;
/* ═══ API 키 관리 ═══ */
async function loadKeys ( ) {
const tbody = $ ( "#keys-tbody" ) ;
try {
const res = await api ( "/v1/keys" ) ;
if ( res . status === 403 ) { tbody . innerHTML = '<tr><td colspan="2" class="err">admin 스코프 키가 필요합니다.</td></tr>' ; return ; }
if ( ! res . ok ) { tbody . innerHTML = '<tr><td colspan="2" class="err">' + esc ( res . status ) + "</td></tr>" ; return ; }
const b = await res . json ( ) ;
tbody . innerHTML = ( b . keys || [ ] ) . map ( ( k ) =>
'<tr><td class="mono">' + esc ( k . id ) + "</td><td>" + ( k . scopes || [ ] ) . map ( esc ) . join ( ", " ) + "</td></tr>"
) . join ( "" ) || '<tr><td colspan="2" class="muted">키 없음</td></tr>' ;
} catch ( e ) {
tbody . innerHTML = '<tr><td colspan="2" class="err">' + esc ( e . message ) + "</td></tr>" ;
}
}
$ ( "#btn-create-key" ) . addEventListener ( "click" , async ( ) => {
const scopes = Array . from ( document . querySelectorAll ( "#tab-keys .chips input:checked" ) ) . map ( ( c ) => c . value ) ;
if ( ! scopes . length ) { toast ( "스코프를 하나 이상 선택하세요" , "err" ) ; return ; }
try {
const res = await api ( "/v1/keys" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( { scopes } ) } ) ;
if ( res . status === 403 ) { toast ( "admin 스코프 키가 필요합니다" , "err" ) ; return ; }
if ( ! res . ok ) { toast ( "생성 실패: " + res . status , "err" ) ; return ; }
const b = await res . json ( ) ;
$ ( "#new-key" ) . textContent = b . key ;
$ ( "#new-key-area" ) . hidden = false ;
toast ( "키 생성됨: " + b . key _id , "ok" ) ;
loadKeys ( ) ;
} catch ( e ) { toast ( e . message , "err" ) ; }
} ) ;
$ ( "#btn-copy-key" ) . addEventListener ( "click" , ( ) => {
const k = $ ( "#new-key" ) . textContent ;
if ( navigator . clipboard ) navigator . clipboard . writeText ( k ) . then ( ( ) => toast ( "복사됨" , "ok" ) ) ;
else { const ta = document . createElement ( "textarea" ) ; ta . value = k ; document . body . appendChild ( ta ) ; ta . select ( ) ; document . execCommand ( "copy" ) ; ta . remove ( ) ; toast ( "복사됨" , "ok" ) ; }
} ) ;
/* ═══ 초기화 ═══ */
initWave ( ) ;
refreshHealth ( ) ;
setInterval ( refreshHealth , 10000 ) ;
if ( apiKey ) probeScope ( ) ;
< / script >
< / body >
< / html >