first commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<!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>${p.branch||"-"}</strong></div><div><span>OWNER</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>
|
||||
Reference in New Issue
Block a user