/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
functionshowOnce(title,value,note=''){constm=modal(`<h2>${R.esc(title)}</h2><p class="err">이 값은 지금 한 번만 표시되며 다시 볼 수 없습니다. 안전한 곳에 바로 옮기세요.</p>${note?`<p class="muted">${R.esc(note)}</p>`:''}<pre class="once" id="once">${R.esc(value)}</pre><div class="actions"><button class="btn" id="cp">복사</button><button class="btn primary" id="ok">닫기(확인했습니다)</button></div>`);$('#cp',m.el).onclick=()=>{navigator.clipboard?.writeText(value);toast('복사했습니다');};$('#ok',m.el).onclick=m.close;}
/** 고위험 작업 3단계 확인(§25): 영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부 */
functionthreeStep({title,impacts=[],identifiers=[],revertible='되돌리기 가능 여부를 확인하세요'}){
returnnewPromise((resolve)=>{
letstep=1;constm=modal('');
constrender=()=>{m.el.innerHTML=`<div class="box"><h2>${R.esc(title)} — ${step}/3단계</h2>${step===1?`<p><b>1단계 영향 범위 확인</b></p><ul>${impacts.map((i)=>`<li>${R.esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="c1"> 위 영향 범위를 확인했습니다</label>`:step===2?`<p><b>2단계 대상 식별정보 재확인</b> — 아래 식별정보를 정확히 입력하세요: <code>${identifiers.map(R.esc).join(', ')}</code></p><input id="c2" placeholder="식별정보(쉼표로 구분)" autocomplete="off">`:`<p><b>3단계 최종 실행</b></p><p>${R.esc(revertible)}</p><label><input type="checkbox" id="c3"> 최종 실행을 확인합니다</label>`}<div class="actions"><button class="btn" id="cancel">취소</button><button class="btn ${step===3?'danger':'primary'}" id="next">${step===3?'실행':'다음'}</button></div></div>`;
domains:async()=>{constout=[];letsrc=null;try{constdom=awaitrelay('/domains');try{src=awaitrelay('/source');}catch(e){out.push(R.statePanel('partial','Source 목록을 받지 못했습니다'));}body().innerHTML=out.join('')+R.domainsView(dom,src);}catch(e){body().innerHTML=R.relayError(e);}},
relocation:async()=>{lettr=null;try{constrl=awaitrelay('/relocation');try{tr=awaitrelay('/transfer');}catch(_){tr=null;}body().innerHTML=(tr?'':R.statePanel('partial','소유권 이전 목록을 받지 못했습니다'))+R.relocationView(rl,tr);}catch(e){body().innerHTML=R.relayError(e);}},
servers:async()=>{try{constr=awaitapi('GET','/ops/api/servers');constb=body();consta=b.querySelector('.actions');b.innerHTML=(a?a.outerHTML:'')+R.serversTable(r.servers)+'<p class="muted">마지막 갱신 '+R.fmt(newDate().toISOString())+'</p>';}catch(e){toast('서버 목록 갱신 실패: '+e.message);}},
login:()=>{constf=$('#login-form');if(!f)return;f.onsubmit=async(e)=>{e.preventDefault();constfd=newFormData(f);try{constr=awaitapi('POST','/ops/api/login',{login:fd.get('login'),password:fd.get('password'),totp:fd.get('totp')||undefined});if(r.mfaRequired){$('#totp-row').hidden=false;$('#msg').textContent=r.message||'인증 코드를 입력하세요';return;}location.href='/ops/';}catch(err){$('#msg').textContent=err.message;}};},
'release-approve':async(b)=>{if(!(awaitask('릴리스 승인',[{name:'ok',label:`릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`,type:'checkbox'}])))return;awaitcall('POST',`/ops/api/releases/${b.v}/approve`);toast('승인했습니다');reload();},
'module-deploy':async(b)=>{constsv=awaitapi('GET','/ops/api/servers');constv=awaitask(`${b.m}${b.v} 배포`,[{name:'serverIds',label:'대상 서버 id(쉼표, 비우면 해당 역할 전체)',hint:sv.servers.map((s)=>`${s.id}=${s.name}(${s.role},${s.status})`).join(' ')}],{note:'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.'});if(!v)return;constr=awaitcall('POST',`/ops/api/modules/${b.m}/deploy`,{version:b.v,serverIds:v.serverIds?v.serverIds.split(',').map((s)=>s.trim()).filter(Boolean):[],nonce:Date.now()});location.href='/ops/jobs/'+r.job.id;},
'module-rollback':async(b)=>{constc=awaitthreeStep({title:`${b.m} 되돌리기`,impacts:[`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`,'되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다','이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'],identifiers:[b.s],revertible:'되돌린 뒤 다시 배포하면 복구할 수 있습니다'});if(!c)return;constr=awaitcall('POST',`/ops/api/modules/${b.m}/rollback`,{...c,serverIds:[b.s]});location.href='/ops/jobs/'+r.job.id;},
'server-new':async()=>{constv=awaitask('서버 등록 — 1. 역할·위치',[{name:'name',label:'서버 이름',required:true},{name:'role',label:'역할',type:'select',options:['web','mail','backup','control']},{name:'location',label:'위치(예: 서울 IDC-1)'}]);if(!v)return;constr=awaitcall('POST','/ops/api/servers',v);location.href='/ops/servers/'+r.server.id;},
'server-token':async(b)=>{constr=awaitcall('POST',`/ops/api/servers/${b.id}/token`);showOnce('일회용 등록 정보(10분·1회)',r.installCommand||r.token,'대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.');},
'server-maint-start':async(b)=>{constv=awaitask('유지보수 시작',[{name:'note',label:'유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)',required:true}],{note:'신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).'});if(!v)return;awaitcall('POST',`/ops/api/servers/${b.id}/maintenance`,{note:v.note});reload();},
'server-delete':async(b)=>{constc=awaitthreeStep({title:'서버 삭제',impacts:[`서버 ${b.id} 와 그 Agent 를 해지합니다`,`배치된 서비스 ${b.hosted}개가 있으면 먼저 이전해야 합니다`,'지표·작업 기록은 남습니다'],identifiers:[b.id],revertible:'삭제 후 복구할 수 없습니다(다시 등록해야 함)'});if(!c)return;awaitcall('POST',`/ops/api/servers/${b.id}/delete`,c);location.href='/ops/servers';},
'incident-new':async()=>{constv=awaitask('사건 등록',[{name:'key',label:'사건 키(같은 원인은 같은 키로 묶임)',required:true},{name:'title',label:'제목',required:true},{name:'severity',label:'심각도',type:'select',options:['info','warning','critical']},{name:'component',label:'구성요소'},{name:'customerImpact',label:'고객 영향(없으면 비움 — 내부 경고는 공개하지 않음)'},{name:'public',label:'공개 상태 페이지에 표시',type:'checkbox'},{name:'publicNote',label:'고객 공지 문구'}]);if(!v)return;awaitcall('POST','/ops/api/incidents',v);reload();},
'incident-public':async(b)=>{constv=awaitask('고객 공지',[{name:'publicNote',label:'공개 문구(개인정보·내부 위치 없이)',type:'textarea',required:true},{name:'public',label:'공개',type:'checkbox',value:true}]);if(!v)return;awaitcall('PATCH',`/ops/api/incidents/${b.id}`,v);reload();},
'incident-resolve':async(b)=>{constv=awaitask('사건 종료',[{name:'verifiedUserPath',label:'실제 사용자 경로(외부 접속·메일 왕복 등)로 복구를 확인했습니다',type:'checkbox'},{name:'note',label:'복구 확인 내용',type:'textarea'}]);if(!v)return;awaitcall('PATCH',`/ops/api/incidents/${b.id}`,{...v,resolve:true});reload();},
'em-declare':async()=>{constv=awaitask('긴급 복구 선언',[{name:'incidentKey',label:'사건 번호(키)',required:true},{name:'reason',label:'복구 이유',type:'textarea',required:true},{name:'scope',label:'허용 대상(쉼표: hal,hal_data,vault,cert_base,artifact_store,first_agent)',required:true}],{danger:true,note:'HAL 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. HAL 이 정상이면 거부됩니다.'});if(!v)return;awaitcall('POST','/ops/api/emergency',{...v,scope:v.scope.split(',').map((s)=>s.trim()).filter(Boolean)});reload();},
'em-approve':async(b)=>{conststep=Number(b.step);constfields=step===1?[{name:'impactText',label:'1단계 — 영향 범위를 적으세요',type:'textarea',required:true}]:step===2?[{name:'identifiers',label:'2단계 — 변경되지 않는 대상 식별정보(쉼표)',required:true}]:[{name:'finalConfirm',label:'3단계 — 최종 실행을 확인합니다',type:'checkbox'},{name:'revertible',label:'되돌리기 가능',type:'checkbox'}];constv=awaitask(`승인 ${step}단계`,fields,{danger:true});if(!v)return;awaitcall('POST',`/ops/api/emergency/${b.id}/approve`,{step,...v,identifiers:v.identifiers?v.identifiers.split(',').map((s)=>s.trim()):undefined});reload();},
'em-credential':async(b)=>{constr=awaitcall('POST',`/ops/api/emergency/${b.id}/credential`);showOnce('긴급 자격(짧은 유효 시간)',r.credential,`만료 ${R.fmt(r.expiresAt)} · 정상화 후 폐기됩니다`);setTimeout(reload,500);},
'em-perform':async(b)=>{constv=awaitask('긴급 작업 수행',[{name:'credential',label:'긴급 자격',type:'password',required:true},{name:'action',label:'작업',type:'select',options:['hal.restart','hal.restore_snapshot','vault.verify','cert.reissue_internal','artifact.restore','agent.reconnect']},{name:'target',label:'대상(선택)'}],{danger:true,note:'허용 범위 밖 작업·만료 자격·일반 배포는 거부됩니다.'});if(!v)return;try{constr=awaitcall('POST',`/ops/api/emergency/${b.id}/perform`,v);toast('수행 결과: '+JSON.stringify(r.result).slice(0,200));}catch(e){toast('거부: '+e.message);}reload();},
'em-verify':async(b)=>{constv=awaitask('실제 결과 확인·자격 폐기',[{name:'note',label:'실제 확인 내용(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)',type:'textarea',required:true}]);if(!v)return;awaitcall('POST',`/ops/api/emergency/${b.id}/verify`,{verify:{note:v.note}});reload();},
'dr-config':async()=>{constv=awaitask('재해복구 설정',[{name:'rto_minutes',label:'복구 목표 시간 RTO(분)',type:'number',required:true},{name:'rpo_minutes',label:'허용 데이터 시점 RPO(분)',type:'number',required:true},{name:'snapshot_interval_minutes',label:'복구 사본 생성 주기(분)',type:'number',required:true},{name:'retention_count',label:'보존 개수',type:'number',required:true},{name:'external_store',label:'외부 보관 위치'}]);if(!v)return;awaitcall('PUT','/ops/api/dr/config',Object.fromEntries(Object.entries(v).map(([k,x])=>[k,k==='external_store'?x:Number(x)])));reload();},
'dr-drill':async(b)=>{awaitcall('POST','/ops/api/dr/drills',{snapshotId:b.id||null});toast('훈련을 시작했습니다 — 실제 복구 뒤 결과를 입력하세요');reload();},
'dr-drill-finish':async(b)=>{constv=awaitask('훈련 결과',[{name:'result',label:'결과',type:'select',options:['success','failed']},{name:'measuredRtoMinutes',label:'측정 RTO(분)',type:'number',required:true},{name:'measuredRpoMinutes',label:'측정 RPO(분)',type:'number',required:true},{name:'findings',label:'실패·조치 항목(줄바꿈)',type:'textarea'},{name:'steps',label:'수행 단계(쉼표)',value:'운영자 로그인,HAL 제어,HTTPS,고객 서비스 외부 확인'}]);if(!v)return;awaitcall('POST',`/ops/api/dr/drills/${b.id}/finish`,{result:v.result,measuredRtoMinutes:Number(v.measuredRtoMinutes),measuredRpoMinutes:Number(v.measuredRpoMinutes),findings:v.findings.split('\n').filter(Boolean),steps:v.steps.split(',').map((s)=>({id:s.trim(),ok:v.result==='success'}))});reload();},
'dr-restore':async(b)=>{constv=awaitask('복구 요청',[{name:'note',label:'이유',type:'textarea',required:true}],{danger:true,note:'복구 시작에는 별도 승인과 기존 제어 위치 중지 확인이 필요합니다. 복구 후 HAL 하나만 운영 상태여야 합니다(§34).'});if(!v)return;awaitcall('POST','/ops/api/dr/restores',{snapshotId:b.id,note:v.note});reload();},
'dr-finish':async(b)=>{constv=awaitask('복구 완료 — 실제 확인 입력',[{name:'login',label:'실제 운영자 로그인 확인',type:'checkbox'},{name:'control',label:'HAL 제어 확인',type:'checkbox'},{name:'https',label:'HTTPS 확인',type:'checkbox'},{name:'customer',label:'고객 서비스 한 개 이상 외부 확인',type:'checkbox'}]);if(!v)return;awaitcall('POST',`/ops/api/dr/restores/${b.id}/finish`,{checks:v});reload();},
'mfa-begin':async()=>{constr=awaitcall('POST','/ops/api/mfa/begin');constv=awaitask('MFA 등록',[{name:'code',label:`인증 앱에 추가(otpauth): ${r.otpauth} — 수동 키: ${r.secret}. 앱의 6자리 코드를 입력하세요`,required:true}]);if(!v)return;constc=awaitcall('POST','/ops/api/mfa/confirm',{code:v.code});showOnce('복구 코드(1회만 표시)',c.recoveryCodes.join('\n'),'각 코드는 한 번만 쓸 수 있습니다.');setTimeout(reload,300);},
'grant-issue':async()=>{constv=awaitask('접근권 발급',[{name:'accountId',label:'운영자 id(비우면 나)'},{name:'hours',label:'유효 시간(1~72시간)',type:'number',value:8},{name:'reason',label:'사유',required:true}]);if(!v)return;constr=awaitcall('POST','/ops/api/access-grants',{accountId:v.accountId||undefined,hours:Number(v.hours),reason:v.reason});showOnce('접근권 URL(1회)',r.grant.url,`허용 네트워크 밖 기기에서 이 URL 을 열면 그 기기에 묶입니다. 만료 ${R.fmt(r.grant.expiresAt)}`);},
'account-new':async()=>{constv=awaitask('운영자 추가',[{name:'login',label:'아이디',required:true},{name:'displayName',label:'표시 이름'},{name:'password',label:'초기 비밀번호(첫 로그인 뒤 변경 강제)',type:'password',required:true}]);if(!v)return;awaitcall('POST','/ops/api/accounts',v);reload();},
// ---- 포털 중계 동작(§26.2·§27·§30)
'cust-change':async(b)=>{constv=awaitask('고객 대신 변경',[{name:'field',label:'항목',type:'select',options:['group.name','group.contact_email','group.contact_phone','site.name','site.maintenance','site.search_visible']},{name:'subjectId',label:'사이트 id(사이트 항목일 때)'},{name:'value',label:'변경 후 값(참/거짓 항목은 1/0)',required:true},{name:'reason',label:'사유',required:true},{name:'approver',label:'승인자'}]);if(!v)return;constr=awaitportal('POST',`/customers/${b.g}/change`,{...v,value:/\.(maintenance|search_visible)$/.test(v.field)?v.value==='1':v.value});toast(`변경: ${JSON.stringify(r.change.before)} → ${JSON.stringify(r.change.after)}`);reload();},
'support-issue':async(b)=>{constv=awaitask('고객 지원용 일시 접근',[{name:'targetType',label:'대상 종류',type:'select',options:['group','site','mail_service']},{name:'targetId',label:'대상 id'},{name:'reason',label:'이유',required:true},{name:'hours',label:'만료(시간, 최대 24)',type:'number',value:4},{name:'scope',label:'허용 범위(쉼표: view,change)',value:'view'},{name:'approver',label:'승인자'}]);if(!v)return;awaitportal('POST','/support-access',{...v,groupId:b.g,hours:Number(v.hours),scope:v.scope.split(',').map((s)=>s.trim())});reload();},
'order-paid':async(b)=>{constv=awaitask('입금 확인',[{name:'ref',label:'입금 확인 참조(입금자·시각 등)',required:true}]);if(!v)return;awaitportal('POST',`/commerce/orders/${b.id}/paid`,v);reload();},
'order-approve':async(b)=>{consto=awaitportal('GET',`/commerce/orders/${b.id}`);constod=o.order||{};constv=awaitask('주문 승인',[{name:'ok',label:`대상 ${od.group?.name||od.group_id} · 금액 ${od.total}${od.currency} · 권리 변화 ${(od.entitlementDelta||[]).map((e)=>e.resource+'+'+e.amount).join(', ')} — 확인했습니다`,type:'checkbox'}]);if(!v||!v.ok)return;constr=awaitportal('POST',`/commerce/orders/${b.id}/approve`,{});toast(r.already?'이미 승인된 주문입니다(권리 중복 생성 없음)':`승인 — 권리 ${r.granted}건 활성화`);reload();},
'product-new-version':async(b)=>{constv=awaitask('새 상품 버전',[{name:'patch',label:'변경 내용(JSON, 예 {"price": 12000})',type:'textarea',required:true}],{note:'판매된 버전은 바꾸지 않고 새 버전을 만듭니다. 기존 고객은 자기 버전을 유지합니다.'});if(!v)return;letpatch;try{patch=JSON.parse(v.patch);}catch(_){returntoast('JSON 형식이 아닙니다');}awaitportal('POST','/commerce/products',{productId:b.p,baseVersionId:b.id,patch});reload();},
'mail-test':async(b)=>{constr=awaitportal('POST',`/mail/services/${b.id}/test`,{});toast('외부 송수신 시험을 시작했습니다'+(r.job?' — 작업 '+r.job.id:''));},
'mail-security':async(b)=>{constr=awaitportal('POST',`/mail/services/${b.id}/security`,{});modal(`<h2>DNS 보안 확인</h2><pre class="pre">${R.esc(JSON.stringify(r,null,1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`);},
'mail-operating':async(b)=>{constv=awaitask('운영 상태(이전 중 두 서버 동시 처리 방지)',[{name:'operating',label:'상태',type:'select',options:['active','migrating_out','migrating_in','frozen']},{name:'reason',label:'사유',required:true}]);if(!v)return;awaitportal('POST',`/mail/services/${b.id}/operating`,v);reload();},
'mail-cancel':async(b)=>{constv=awaitask('메일 서비스 정지·해지',[{name:'reason',label:'사유',required:true},{name:'ok',label:'송수신이 즉시 중지되고 30일 읽기·내보내기 뒤 삭제됩니다 — 확인',type:'checkbox'}],{danger:true});if(!v||!v.ok)return;awaitportal('POST',`/mail/services/${b.id}/cancel`,{reason:v.reason});reload();},
'backup-retry':async(b)=>{awaitportal('POST',`/backup/options/${b.id}/retry`,{});reload();},'backup-override':async(b)=>{constv=awaitask('같은 서버 저장 허용(예외)',[{name:'reason',label:'사유',required:true}],{danger:true});if(!v)return;awaitportal('POST',`/backup/options/${b.id}/override-same-server`,v);reload();},
'restore-approve':async(b)=>{constd=awaitportal('GET',`/backup/restores/${b.id}`);constr=d.restore||d;constfc=d.finalConfirmation||{};constv=awaitask('복원 승인 — 최종 확인',[{name:'ok',label:`고객 그룹 ${fc.groupName||r.group_name} · 사이트 ${fc.siteName||r.site_name} · 도메인 ${fc.primaryDomain||r.primary_domain||'—'} · 백업 시각 ${R.fmt(fc.backupAt||r.backup_created_at)} — 위 내용을 최종 확인합니다`,type:'checkbox'}]);if(!v||!v.ok)return;awaitportal('POST',`/backup/restores/${b.id}/approve`,{confirm:fc});reload();},
'reloc-approve':async(b)=>{constd=awaitportal('GET',`/relocation/${b.id}`);constfc=d.finalConfirmation||{subjectId:d.relocation?.subject_id,toServerId:d.relocation?.to_server_id};constv=awaitask('이전 승인 — 최종 확인',[{name:'scheduledAt',label:'작업 시간(ISO, 비우면 즉시)'},{name:'ok',label:`대상 ${fc.subjectName||fc.subjectId} → 서버 ${fc.toServerId} · 전환 직전 조건이 달라지면 자동 중지됩니다 — 확인`,type:'checkbox'}]);if(!v||!v.ok)return;awaitportal('POST',`/relocation/${b.id}/approve`,{scheduledAt:v.scheduledAt||null,confirm:fc});reload();},
'reloc-retry':async(b)=>{awaitportal('POST',`/relocation/${b.id}/retry`,{});reload();},'reloc-resume':async(b)=>{awaitportal('POST',`/relocation/${b.id}/resume`,{});reload();},'reloc-revert':async(b)=>{constv=awaitask('이전 되돌리기',[{name:'ok',label:'새 위치를 운영 위치로 확정하지 않고 이전 위치로 돌립니다 — 확인',type:'checkbox'}],{danger:true});if(!v||!v.ok)return;awaitportal('POST',`/relocation/${b.id}/revert`,{});reload();},
'reloc-new':async()=>{constv=awaitask('플랫폼 사유 이전',[{name:'subjectType',label:'대상 종류',type:'select',options:['site','mail']},{name:'subjectId',label:'대상 id',required:true},{name:'toServerId',label:'도착 서버 id(비우면 자동 선택)'},{name:'reason',label:'이유(장애 회피·용량 조정 — 고객 과금 없음)',required:true}]);if(!v)return;awaitportal('POST','/relocation',v);reload();},
'transfer-approve':async(b)=>{constd=awaitportal('GET',`/transfer/${b.id}`);constfc=d.finalConfirmation||{};constv=awaitask('소유권 이전 승인',[{name:'proof',label:'소유 증빙 확인 내용',required:true},{name:'domains',label:'도메인 확인 결과',required:true},{name:'ok',label:`${fc.fromGroupId} → ${fc.toGroupId} 전체가 한 번에 이전됩니다(일부만 이전되지 않음) — 확인`,type:'checkbox'}]);if(!v||!v.ok)return;awaitportal('POST',`/transfer/${b.id}/approve`,{review:{proof:v.proof,domains:v.domains},confirm:fc});reload();},
'transfer-reject':async(b)=>{constv=awaitask('소유권 이전 거절',[{name:'reason',label:'사유',required:true}],{danger:true});if(!v)return;awaitportal('POST',`/transfer/${b.id}/reject`,v);reload();},