Files
linkitapps 47181fa5ca feat: Gitea 연동 설정 보완, Apache 역방향 프록시 템플릿 추가 및 기능 개선 요청서 작성
- fix(config): 로컬 실행 시 루트의 .env 파일을 자동 로드하도록 Settings.from_env 개선 (테스트 실행 시 격리)

- fix(ops): 기존 8000번 포트 충돌 방지를 위해 docker-compose.ops.yml 호스트 포트를 127.0.0.1:18000:8000으로 조정

- fix(git-binding): Git 저장소 바인딩 시 projects 테이블의 branch도 함께 동기화하도록 app.py 보완

- ui(projects): 프로젝트 카드의 BRANCH 표시에 바인딩된 브랜치를 우선 표시하고, OWNER 라벨을 '담당'으로 명확화

- feat(deploy): Ubuntu Apache2 웹서버용 Gitea 및 SourceHub 역방향 프록시 설정 템플릿 추가 (deploy/apache-*.conf)

- docs: 프로젝트 CRUD 및 사내 개발자 관리 기능 갭 분석 및 개발 요청서 Word 문서 추가 (docs/)
2026-09-27 01:23:47 +09:00

92 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>NEXPA SourceHub 3.8 · Production Candidate</title>
<style>
:root{--navy:#071f46;--navy2:#0d376f;--blue:#1f5fbf;--gold:#c9a85d;--bg:#f4f6f9;--surface:#fff;--text:#172338;--muted:#748195;--line:#dce3ec;--green:#17805b;--greenBg:#e9f5ef;--amber:#a86c12;--amberBg:#fff2d8;--red:#b94747;--redBg:#fceaea;--shadow:0 16px 38px rgba(7,31,70,.08)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:"Malgun Gothic","Apple SD Gothic Neo",sans-serif}button,input,select,textarea{font:inherit}button{cursor:pointer}
.shell{display:flex;min-height:100vh}.side{width:270px;position:fixed;inset:0 auto 0 0;background:var(--navy);color:#fff;padding:28px 20px 22px;display:flex;flex-direction:column;z-index:30}.brand{padding:2px 8px 25px;border-bottom:1px solid rgba(255,255,255,.14)}.brand strong{font-size:28px;letter-spacing:.09em}.brand i{display:block;width:42px;height:3px;background:var(--gold);margin:12px 0}.brand span{display:block;font-size:12px;font-weight:900;letter-spacing:.18em}.brand small{display:block;color:#9fb0c8;font-size:9px;margin-top:5px}.nav{display:grid;gap:7px;margin-top:22px}.nav button{border:0;background:transparent;color:#b8c5d8;text-align:left;padding:14px;border-radius:9px;font-weight:800}.nav button.active,.nav button:hover{background:rgba(255,255,255,.1);color:#fff}.rolebox{margin-top:auto;border-top:1px solid rgba(255,255,255,.14);padding-top:16px}.rolebox label{display:block;color:#9fb0c8;font-size:9px;letter-spacing:.13em;margin-bottom:7px}.rolebox select,.rolebox button{width:100%;padding:11px;border-radius:8px}.rolebox select{background:#12355f;color:#fff;border:1px solid rgba(255,255,255,.16)}.rolebox button{margin-top:9px;background:#fff;color:var(--navy);border:0;font-weight:900}
.main{margin-left:270px;width:calc(100% - 270px);padding:0 32px 60px}.top,.page{max-width:1420px;margin:auto}.top{height:80px;position:sticky;top:0;z-index:20;background:rgba(244,246,249,.94);backdrop-filter:blur(16px);display:flex;align-items:center;justify-content:space-between}.crumb small{display:block;color:#8794a6;font-size:9px;letter-spacing:.14em}.crumb strong{font-size:15px}.topright{display:flex;gap:8px}.pill{padding:7px 10px;border-radius:999px;background:var(--greenBg);color:var(--green);font-size:9px;font-weight:900}.page{display:none}.page.active{display:block}
.hero{margin:12px 0 20px;background:linear-gradient(130deg,var(--navy),var(--navy2));color:#fff;border-radius:18px;padding:42px;display:grid;grid-template-columns:1.1fr .9fr;gap:28px;box-shadow:0 24px 54px rgba(10,31,68,.14)}.hero small{font-size:10px;letter-spacing:.18em;color:#b7ccec;font-weight:900}.hero h1{font-size:40px;line-height:1.25;margin:14px 0}.hero p{color:#cfdbeb;font-size:13px;max-width:650px;line-height:1.75}.heroActions{display:grid;gap:11px}.heroActions button{min-height:86px;border-radius:12px;padding:18px;text-align:left;display:flex;align-items:center;justify-content:space-between;font-size:18px;font-weight:900}.heroActions button:first-child{border:0;background:#fff;color:var(--navy)}.heroActions button:not(:first-child){border:1px solid rgba(255,255,255,.28);background:rgba(255,255,255,.07);color:#fff}
.box{background:#fff;border:1px solid var(--line);border-radius:15px;box-shadow:var(--shadow);padding:22px;margin-bottom:18px}.head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:15px}.head small{display:block;color:var(--blue);font-size:9px;font-weight:900;letter-spacing:.15em}.head h2,.intro h1{margin:4px 0 0}.head h2{font-size:20px}.head p,.intro p{font-size:11px;color:var(--muted);line-height:1.7;margin:6px 0 0}.intro{background:#fff;border:1px solid var(--line);border-radius:15px;box-shadow:var(--shadow);padding:25px 27px;margin:12px 0 18px;display:flex;justify-content:space-between;align-items:flex-end;gap:18px}.intro h1{font-size:34px}
.primary,.secondary,.danger{min-height:46px;border-radius:9px;padding:0 16px;font-weight:900}.primary{border:0;background:var(--navy);color:#fff}.secondary{border:1px solid var(--line);background:#fff;color:var(--navy)}.danger{border:0;background:var(--redBg);color:var(--red)}
.statusGrid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.statusCard{border:1px solid var(--line);border-radius:12px;padding:16px;background:#fff}.statusCard span,.statusCard strong,.statusCard small{display:block}.statusCard span{font-size:9px;color:var(--muted);letter-spacing:.08em}.statusCard strong{font-size:17px;margin:5px 0}.statusCard small{font-size:9px;color:#8794a6}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:5px}.dot.off{background:var(--amber)}
.summary{display:grid;grid-template-columns:repeat(4,1fr);padding:0;overflow:hidden}.summary div{padding:18px 20px;border-right:1px solid var(--line)}.summary div:last-child{border:0}.summary span,.summary strong,.summary small{display:block}.summary span{font-size:9px;color:var(--muted)}.summary strong{font-size:27px;margin:3px 0}.summary small{font-size:9px;color:#8b97a7}
.baseRow{display:grid;grid-template-columns:170px 1fr auto;gap:15px;align-items:center;padding:13px 0;border-bottom:1px solid var(--line)}.baseRow:last-child{border:0}.baseRow b{font-size:12px}.baseRow strong,.baseRow small{display:block}.baseRow small{font-size:10px;color:var(--muted);margin-top:3px;word-break:break-all}.tag{display:inline-flex;padding:6px 9px;border-radius:999px;background:#eaf1fb;color:var(--blue);font-size:9px;font-weight:900;white-space:nowrap}
.projects{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.card{border:1px solid var(--line);border-radius:13px;padding:20px;background:#fff}.card h3{font-size:17px;margin:0}.card p{font-size:10px;color:var(--muted);line-height:1.6}.meta{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:15px 0}.meta div{background:#f7f9fb;border-radius:8px;padding:10px}.meta span,.meta strong{display:block}.meta span{font-size:8px;color:var(--muted)}.meta strong{font-size:10px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar{height:7px;background:#e9edf4;border-radius:999px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--blue),#72aaf0)}
.filters{display:grid;grid-template-columns:180px 180px 1fr auto;gap:9px;padding:14px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:var(--shadow);margin-bottom:16px}.filters input,.filters select,.form input,.form select,.form textarea{width:100%;border:1px solid var(--line);border-radius:8px;padding:10px;background:#fff}.transferTop{display:flex;justify-content:space-between;gap:14px}.transferTop h3{margin:0;font-size:17px}.transferTop p{font-size:10px;color:var(--muted)}.state{padding:6px 10px;border-radius:999px;font-size:9px;font-weight:900;height:max-content}.UPLOADED{background:#edf2f8;color:#66758a}.RECEIVED,.QA_REVIEW,.CONTENT_REVIEW,.DEPLOY_REVIEW{background:var(--amberBg);color:var(--amber)}.APPROVED{background:var(--greenBg);color:var(--green)}.CHANGES_REQUESTED{background:var(--redBg);color:var(--red)}.steps{display:flex;gap:6px;align-items:center;overflow:auto;margin-top:13px}.steps span{padding:6px 9px;border-radius:999px;background:#edf1f6;color:#7a8798;font-size:9px;white-space:nowrap}.steps span.ACTIVE{background:var(--amberBg);color:var(--amber)}.steps span.DONE{background:var(--greenBg);color:var(--green)}.steps span.CHANGES_REQUESTED{background:var(--redBg);color:var(--red)}.actions{display:flex;gap:8px;justify-content:flex-end;margin-top:13px}.rev{display:flex;justify-content:space-between;gap:12px;align-items:center;border-top:1px solid var(--line);padding:10px 0}.rev small{font-size:9px;color:var(--muted)}
.checks{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.check{display:flex;justify-content:space-between;background:#f7f9fb;border-radius:8px;padding:10px;font-size:10px}.ok{color:var(--green);font-weight:900}.wait{color:var(--amber);font-weight:900}.auditRow{display:grid;grid-template-columns:150px 120px 1fr auto;gap:10px;padding:13px 0;border-bottom:1px solid var(--line);align-items:center}.auditRow:last-child{border:0}.auditRow small{font-size:9px;color:var(--muted)}
.modalWrap{position:fixed;inset:0;background:rgba(5,20,45,.62);display:none;align-items:center;justify-content:center;padding:18px;z-index:100}.modalWrap.open{display:flex}.modal{width:min(740px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:15px;padding:24px}.close{float:right;border:0;background:transparent;font-size:24px}.form{display:grid;gap:12px}.form2{display:grid;grid-template-columns:1fr 1fr;gap:10px}.form label{font-size:10px;font-weight:900}.note{font-size:10px;color:var(--muted);line-height:1.7}.toast{position:fixed;right:22px;bottom:22px;background:var(--navy);color:#fff;padding:12px 15px;border-radius:9px;opacity:0;transform:translateY(10px);transition:.2s;z-index:200}.toast.show{opacity:1;transform:none}
@media(max-width:1050px){.hero{grid-template-columns:1fr}.statusGrid,.summary{grid-template-columns:repeat(2,1fr)}.projects{grid-template-columns:1fr}}
@media(max-width:760px){.side{transform:translateX(-100%)}.main{margin-left:0;width:100%;padding:0 14px 40px}.hero{padding:24px}.hero h1{font-size:30px}.statusGrid,.summary{grid-template-columns:1fr}.baseRow{grid-template-columns:1fr}.filters{grid-template-columns:1fr}.meta,.form2,.checks{grid-template-columns:1fr}.auditRow{grid-template-columns:1fr}.intro{flex-direction:column;align-items:flex-start}}
</style>
</head><body>
<div class="shell">
<aside class="side"><div class="brand"><strong>NEXPA</strong><i></i><span>SOURCEHUB 3.8</span><small>Production Candidate · Secure Team Ops</small></div><div class="nav"><button class="active" data-page="dashboard">통합 현황</button><button data-page="projects">프로젝트 · Git</button><button data-page="transfers">소스 전달</button><button data-page="deployments">배포 관리</button><button data-page="audit">감사 로그</button></div><div class="rolebox"><label>AUTH / CURRENT ROLE</label><select id="role"></select><button id="loginBtn" style="display:none">NEXPA Core 로그인</button></div></aside>
<main class="main"><header class="top"><div class="crumb"><small>NEXPA SOURCEHUB</small><strong id="pageTitle">통합 현황</strong></div><div class="topright"><span class="pill" id="serverPill">SERVER</span><span class="pill" id="userPill">USER</span></div></header>
<section class="page active" id="dashboard">
<div class="hero"><div><small>AUTH · DATABASE · OBJECT STORAGE · GIT</small><h1>개발 협업을<br>운영 인프라와 연결합니다.</h1><p>3.8은 로그인 Gate, 악성파일 검사, 만료형 다운로드, 통제된 배포 실행, 백업·복구 기준까지 포함한 실사이트 교체용 Production Candidate입니다.</p></div><div class="heroActions"><button onclick="openUpload()">새 소스 전달 <span>→</span></button><button onclick="go('projects')">프로젝트 · Git <span>→</span></button><button onclick="go('deployments')">배포 Gate <span>→</span></button></div></div>
<div class="box"><div class="head"><div><small>OPERATIONS CONNECTION</small><h2>운영 연결 상태</h2></div></div><div class="statusGrid" id="statusGrid"></div></div>
<div class="box summary" id="summary"></div>
<div class="box"><div class="head"><div><small>CURRENT BASELINE</small><h2>현재 기준 패키지</h2></div><span class="tag">7 BASELINES</span></div><div id="baselines"></div></div>
</section>
<section class="page" id="projects"><div class="intro"><div><small style="color:var(--blue);font-weight:900">PROJECT + GIT BINDING</small><h1>프로젝트 · Git</h1><p>실제 저장소 주소는 임의 생성하지 않고 프로젝트별로 바인딩합니다.</p></div></div><div class="projects" id="projectGrid"></div></section>
<section class="page" id="transfers"><div class="intro"><div><small style="color:var(--blue);font-weight:900">IMMUTABLE SOURCE TRANSFER</small><h1>소스 전달</h1><p>실제 파일 저장 / 서버 SHA-256 / Revision / 역할별 승인</p></div><button class="primary" onclick="openUpload()">새 소스 전달</button></div><div class="filters"><select id="projectFilter"></select><select id="stateFilter"><option value="">전체 상태</option><option>UPLOADED</option><option>RECEIVED</option><option>QA_REVIEW</option><option>CONTENT_REVIEW</option><option>DEPLOY_REVIEW</option><option>APPROVED</option><option>CHANGES_REQUESTED</option></select><input id="q" placeholder="버전·변경요약 검색"><button class="secondary" onclick="loadTransfers()">검색</button></div><div id="transferList"></div></section>
<section class="page" id="deployments"><div class="intro"><div><small style="color:var(--blue);font-weight:900">DEPLOYMENT GATES</small><h1>배포 관리</h1><p>서비스별 현재 배포 준비 상태와 운영 연결 Gate를 확인합니다.</p></div></div><div id="deploymentList"></div></section>
<section class="page" id="audit"><div class="intro"><div><small style="color:var(--blue);font-weight:900">AUDIT TRAIL</small><h1>감사 로그</h1><p>전달·Revision·승인·Git 바인딩·댓글을 DB에 기록합니다.</p></div><button class="secondary" onclick="loadAudit()">새로고침</button></div><div class="box" id="auditList"></div></section>
</main></div>
<div class="modalWrap" id="uploadModal"><div class="modal"><button class="close" onclick="closeModal('uploadModal')">×</button><h2>새 소스 전달</h2><form class="form" id="uploadForm"><div class="form2"><label>프로젝트<select name="project_code" id="uploadProject"></select></label><label>버전<input name="version" required></label></div><div class="form2"><label>받는 역할<select name="receiver_role" id="receiver"></select></label><label>파일 유형<select name="file_type"><option>Source ZIP</option><option>Patch</option><option>Build Artifact</option><option>Document</option><option>Asset Package</option></select></label></div><label>파일<input type="file" name="file" required></label><div class="form2"><label>Git Ref<input name="git_ref"></label><label>Branch<input name="branch"></label></div><label>변경 요약<textarea name="summary" rows="4" required></textarea></label><p class="note">운영 설정이 MinIO이면 Object Storage로, 기본 설정이면 로컬 immutable storage로 저장됩니다.</p><div class="actions"><button type="button" class="secondary" onclick="closeModal('uploadModal')">취소</button><button class="primary">전달 등록</button></div></form></div></div>
<div class="modalWrap" id="detailModal"><div class="modal"><button class="close" onclick="closeModal('detailModal')">×</button><div id="detail"></div></div></div>
<div class="modalWrap" id="gitModal"><div class="modal"><button class="close" onclick="closeModal('gitModal')">×</button><h2>Gitea 저장소 바인딩</h2><form class="form" id="gitForm"><input type="hidden" name="project_code"><div class="form2"><label>Owner / Organization<input name="owner" required></label><label>Repository<input name="repo" required></label></div><label>기준 Branch<input name="branch" required></label><p class="note">실제 Gitea 주소와 Token은 서버 `.env`에 저장하고 화면에는 노출하지 않습니다.</p><div class="actions"><button class="secondary" type="button" onclick="closeModal('gitModal')">취소</button><button class="primary">바인딩 저장</button></div></form></div></div>
<div class="toast" id="toast"></div>
<script>
let csrfToken="";
const roles={dev1:"개발자 1 · 백엔드",dev2:"개발자 2 · 프론트엔드",dev3:"개발자 3 · 통합/QA",planner:"콘텐츠 기획가",deploy:"배포 담당자"};
let currentRole=localStorage.getItem("sourcehubRole")||"dev1",projects=[],system={};
const $=s=>document.querySelector(s);
function headers(json=true){const h={"X-Role":currentRole};if(csrfToken)h["X-CSRF-Token"]=csrfToken;if(json)h["Content-Type"]="application/json";return h}
function formHeaders(){const h={"X-Role":currentRole};if(csrfToken)h["X-CSRF-Token"]=csrfToken;return h}
async function api(url,opt={}){const r=await fetch(url,opt);if(r.status===401){toast("로그인이 필요합니다.");throw new Error("로그인이 필요합니다.")}if(!r.ok){let t=await r.text();throw new Error(t)}return r.headers.get("content-type")?.includes("json")?r.json():r}
function parseErr(e){try{return JSON.parse(e.message).detail||e.message}catch{return e.message}}
function toast(m){const t=$("#toast");t.textContent=m;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),2200)}
function go(id){document.querySelectorAll(".page").forEach(x=>x.classList.remove("active"));$("#"+id).classList.add("active");document.querySelectorAll(".nav button").forEach(x=>x.classList.toggle("active",x.dataset.page===id));$("#pageTitle").textContent={dashboard:"통합 현황",projects:"프로젝트 · Git",transfers:"소스 전달",deployments:"배포 관리",audit:"감사 로그"}[id];if(id==="transfers")loadTransfers();if(id==="audit")loadAudit();if(id==="deployments")loadDeployments();scrollTo(0,0)}
document.querySelectorAll(".nav button").forEach(b=>b.onclick=()=>go(b.dataset.page));function openModal(id){$("#"+id).classList.add("open")}function closeModal(id){$("#"+id).classList.remove("open")}function openUpload(){openModal("uploadModal")}
async function bootstrap(){
Object.entries(roles).forEach(([k,v])=>$("#role").insertAdjacentHTML("beforeend",`<option value="${k}">${v}</option>`));$("#role").value=currentRole;
$("#role").onchange=()=>{currentRole=$("#role").value;localStorage.setItem("sourcehubRole",currentRole);loadAll()};
$("#loginBtn").onclick=()=>location.href="/auth/login";
try{system=await api("/api/system/status");$("#serverPill").textContent=system.production_ready?"PROD READY":"CONFIG NEEDED";renderSystem();const cs=await api("/api/security/csrf",{headers:{"X-Role":currentRole}});csrfToken=cs.token||""}catch(e){$("#serverPill").textContent="SERVER ERROR";toast(parseErr(e));return}
if(system.auth.mode==="oidc"){ $("#role").style.display="none";$("#loginBtn").style.display=system.auth.authenticated?"none":"block";$("#userPill").textContent=system.auth.authenticated?(system.auth.user?.name||system.auth.user?.email||"OIDC USER"):"LOGIN REQUIRED"} else {$("#userPill").textContent=roles[currentRole]}
if(system.auth.mode==="oidc" && !system.auth.authenticated){
$("#summary").innerHTML='<div><span>AUTH</span><strong>로그인 필요</strong><small>NEXPA Core OIDC</small></div>';
return;
}
projects=await api("/api/projects",{headers:{"X-Role":currentRole}});populateProjects();await loadDashboard();await loadTransfers();await loadDeployments();await loadAudit()
}
function renderSystem(){const cards=[["DATABASE",system.database.kind,system.database.connected],["STORAGE",system.storage.backend,system.storage.connected],["AUTH",system.auth.mode,system.auth.configured],["GIT","Gitea",system.git.configured],["MALWARE",system.security.malware_scan,system.security.malware_scan_configured],["DEPLOY",system.deploy.mode,system.deploy.configured]];$("#statusGrid").innerHTML=cards.map(c=>`<div class="statusCard"><span>${c[0]}</span><strong><i class="dot ${c[2]?"":"off"}"></i>${c[1]}</strong><small>${c[2]?"연결 준비":"설정 필요"}</small></div>`).join("")}
function populateProjects(){const opts=projects.map(p=>`<option value="${p.code}">${p.name}</option>`).join("");$("#uploadProject").innerHTML=opts;$("#projectFilter").innerHTML='<option value="">전체 프로젝트</option>'+opts;$("#receiver").innerHTML=Object.entries(roles).map(([k,v])=>`<option value="${k}">${v}</option>`).join("");renderProjects()}
async function loadAll(){try{system=await api("/api/system/status");$("#userPill").textContent=roles[currentRole];await loadDashboard();await loadTransfers();await loadAudit()}catch(e){toast(parseErr(e))}}
async function loadDashboard(){const [b,t]=await Promise.all([api("/api/baselines",{headers:{"X-Role":currentRole}}),api("/api/transfers",{headers:{"X-Role":currentRole}})]);$("#summary").innerHTML=`<div><span>기준 프로젝트</span><strong>${projects.length}</strong><small>현재 운영 기준</small></div><div><span>진행 전달</span><strong>${t.filter(x=>x.state!=="APPROVED").length}</strong><small>승인 흐름 포함</small></div><div><span>승인 완료</span><strong>${t.filter(x=>x.state==="APPROVED").length}</strong><small>immutable records</small></div><div><span>수정 요청</span><strong>${t.filter(x=>x.state==="CHANGES_REQUESTED").length}</strong><small>새 Revision 필요</small></div>`;$("#baselines").innerHTML=b.map(x=>`<div class="baseRow"><b>${x.kind}</b><div><strong>${x.project_name} ${x.version}</strong><small>${x.package_name}</small></div><span class="tag">${x.state}</span></div>`).join("")}
function renderProjects(){$("#projectGrid").innerHTML=projects.map(p=>{const b=p.repo_binding||{};return `<article class="card"><div style="display:flex;justify-content:space-between;gap:10px"><div><h3>${p.name}</h3><p>현재 기준 ${p.current_version}</p></div><span class="tag">v${p.current_version}</span></div><div class="meta"><div><span>BRANCH</span><strong>${b.branch||p.branch||"-"}</strong></div><div><span>담당</span><strong>${p.owner}</strong></div><div><span>GIT</span><strong>${b.repo?b.owner+"/"+b.repo:"미연결"}</strong></div></div><div class="bar"><i style="width:${p.progress}%"></i></div><div class="actions"><button class="secondary" onclick="checkGit('${p.code}')">연결 확인</button><button class="primary" onclick="openGit('${p.code}')">저장소 바인딩</button></div></article>`}).join("")}
async function checkGit(code){try{const r=await api(`/api/projects/${code}/git/status`,{headers:{"X-Role":currentRole}});toast(r.status.message+(r.status.commit?` · ${r.status.commit.slice(0,8)}`:""))}catch(e){toast(parseErr(e))}}
function openGit(code){const p=projects.find(x=>x.code===code),b=p.repo_binding||{};const f=$("#gitForm");f.project_code.value=code;f.owner.value=b.owner||"";f.repo.value=b.repo||"";f.branch.value=b.branch||p.branch||"";openModal("gitModal")}
$("#gitForm").onsubmit=async e=>{e.preventDefault();const fd=new FormData(e.target),code=fd.get("project_code");try{await api(`/api/projects/${code}/git/bind`,{method:"POST",headers:headers(),body:JSON.stringify({owner:fd.get("owner"),repo:fd.get("repo"),branch:fd.get("branch")})});closeModal("gitModal");projects=await api("/api/projects",{headers:{"X-Role":currentRole}});renderProjects();toast("Git 바인딩이 저장되었습니다.")}catch(err){toast(parseErr(err))}};
async function loadTransfers(){const qs=new URLSearchParams();if($("#projectFilter").value)qs.set("project",$("#projectFilter").value);if($("#stateFilter").value)qs.set("state",$("#stateFilter").value);if($("#q").value)qs.set("q",$("#q").value);const t=await api("/api/transfers?"+qs,{headers:{"X-Role":currentRole}});$("#transferList").innerHTML=t.map(x=>`<article class="box"><div class="transferTop"><div><h3>${x.project} ${x.version}</h3><p>${x.summary}</p></div><span class="state ${x.state}">${x.state}</span></div><div class="meta"><div><span>LATEST</span><strong>${x.revisions[0]?.filename||"-"}</strong></div><div><span>SHA-256</span><strong>${x.revisions[0]?.sha256?.slice(0,16)||"-"}...</strong></div><div><span>FROM → TO</span><strong>${x.sender} → ${x.receiver}</strong></div></div><div class="steps">${x.steps.map(s=>`<span class="${s.state}">${s.label}</span>`).join("")}</div><div class="actions"><button class="secondary" onclick="openDetail('${x.id}')">상세 · Revision</button></div></article>`).join("")||'<div class="box"><p class="note">전달 기록이 없습니다.</p></div>'}
async function openDetail(id){const t=await api("/api/transfers/"+id,{headers:{"X-Role":currentRole}}),active=t.steps.find(s=>s.state==="ACTIVE");$("#detail").innerHTML=`<h2>${t.project} ${t.version}</h2><p class="note">${t.summary}</p><div class="steps">${t.steps.map(s=>`<span class="${s.state}">${s.label} · ${s.assignee}</span>`).join("")}</div><h3 style="font-size:13px;margin-top:20px">Revision</h3>${t.revisions.map(r=>`<div class="rev"><div><strong>R${r.revision_no} · ${r.filename}</strong><br><small>${r.sha256}<br>${(r.file_size/1024/1024).toFixed(2)} MB · ${r.created_by}</small></div><a class="secondary" style="display:inline-flex;align-items:center;text-decoration:none;font-size:10px" href="#" onclick="downloadRevision('${r.id}');return false">다운로드</a></div>`).join("")}<h3 style="font-size:13px">새 Revision</h3><form id="revForm" class="form"><input type="file" name="file" required><input name="summary" placeholder="변경 요약"><button class="secondary">Revision 추가</button></form><h3 style="font-size:13px">협업 메모</h3>${t.comments.map(c=>`<div class="rev"><div><strong>${c.author}</strong><br><small>${c.body}</small></div></div>`).join("")||'<p class="note">댓글 없음</p>'}<form id="commentForm" class="form"><textarea name="body" rows="2"></textarea><button class="secondary">댓글 등록</button></form><div class="actions">${active?`<button class="danger" onclick="requestChanges('${t.id}')">수정 요청</button><button class="primary" onclick="approve('${t.id}')">${active.label} 승인</button>`:'<span class="tag">승인 가능한 단계 없음</span>'}</div>`;$("#revForm").onsubmit=async e=>{e.preventDefault();try{await api(`/api/transfers/${id}/revisions`,{method:"POST",headers:formHeaders(),body:new FormData(e.target)});toast("새 Revision 등록 완료");await openDetail(id);await loadTransfers();await loadDashboard()}catch(err){toast(parseErr(err))}};$("#commentForm").onsubmit=async e=>{e.preventDefault();try{await api(`/api/transfers/${id}/comments`,{method:"POST",headers:headers(),body:JSON.stringify({body:new FormData(e.target).get("body")})});await openDetail(id);await loadAudit()}catch(err){toast(parseErr(err))}};openModal("detailModal")}
async function downloadRevision(id){try{const r=await api(`/api/revisions/${id}/download-link`,{headers:{"X-Role":currentRole}});location.href=r.url}catch(e){toast(parseErr(e))}}
async function approve(id){const note=prompt("승인 메모(선택)")||"";try{await api(`/api/transfers/${id}/approve`,{method:"POST",headers:headers(),body:JSON.stringify({note})});closeModal("detailModal");await loadTransfers();await loadDashboard();await loadAudit();toast("승인 완료")}catch(e){toast(parseErr(e))}}
async function requestChanges(id){const note=prompt("수정 요청 내용")||"수정 필요";try{await api(`/api/transfers/${id}/request-changes`,{method:"POST",headers:headers(),body:JSON.stringify({note})});closeModal("detailModal");await loadTransfers();await loadDashboard();await loadAudit();toast("수정 요청 기록 완료")}catch(e){toast(parseErr(e))}}
$("#uploadForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/transfers",{method:"POST",headers:formHeaders(),body:new FormData(e.target)});e.target.reset();closeModal("uploadModal");await loadTransfers();await loadDashboard();await loadAudit();toast("파일 저장 · SHA-256 · R1 등록 완료")}catch(err){toast(parseErr(err))}};
async function loadDeployments(){const d=await api("/api/deployments",{headers:{"X-Role":currentRole}});$("#deploymentList").innerHTML=d.map(x=>`<article class="box"><div class="head"><div><small>${x.environment}</small><h2>${x.project} ${x.version}</h2></div><span class="tag">${x.status}</span></div><div class="checks">${x.checks.map(c=>`<div class="check"><span>${c[0]}</span><b class="${c[1]?"ok":"wait"}">${c[1]?"완료":"대기"}</b></div>`).join("")}</div><div class="actions"><button class="primary" onclick="executeDeployment(${x.id})">배포 실행 요청</button></div></article>`).join("")}
async function executeDeployment(id){if(!confirm("모든 Gate가 완료된 배포만 실행됩니다. 계속할까요?"))return;try{const r=await api(`/api/deployments/${id}/execute`,{method:"POST",headers:headers(),body:"{}"});toast(`배포 요청 승인 · ${r.run_id}`);await loadAudit()}catch(e){toast(parseErr(e))}}
async function loadAudit(){const a=await api("/api/audit?limit=120",{headers:{"X-Role":currentRole}});$("#auditList").innerHTML=a.map(x=>`<div class="auditRow"><small>${new Date(x.at).toLocaleString("ko-KR")}</small><strong style="font-size:10px">${x.actor}</strong><div><strong style="font-size:11px">${x.action}</strong><br><small>${x.target}</small></div><span class="tag">${x.type}</span></div>`).join("")||'<p class="note">감사 로그가 없습니다.</p>'}
bootstrap();
</script></body></html>