68f85f3736
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼, ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지 - 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code) 동시 반환 - 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼): CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 + CompanyMgmtController + SuperAdminGuard - 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종 (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport - 프로비저닝 마법사: force_password_change 토글 반영 - 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED) 전역 리다이렉트 - 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
914 lines
31 KiB
TypeScript
914 lines
31 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Check, X, AlertOctagon, Rocket, Sparkles, Terminal, Zap } from "lucide-react";
|
|
import { createCompany, getProvisioningStatus, CreateCompanyRequest } from "@/lib/api/provisioning";
|
|
|
|
/**
|
|
* Step 4 · 생성 진행 — 대대적 개편.
|
|
* 좌측: 중앙 pulsing orb + 진행률 + 단계 타임라인 + 결과 배너
|
|
* 우측: 터미널 로그 (auto-scroll)
|
|
*
|
|
* 로직 (유지):
|
|
* - 마운트 즉시 POST /companies → jobId 취득 → 폴링 시작
|
|
* - 폴링 결과 status.currentStep 변화에 맞춰 단계 state 갱신
|
|
* - onDone 으로 최종 결과 상위에 통보
|
|
*/
|
|
|
|
const DISPLAY_STEPS: { key: string; label: string; sub: string; sec: number }[] = [
|
|
{ key: "CREATE_DATABASE", label: "DB 생성", sub: "CREATE DATABASE '%db%'", sec: 2 },
|
|
{ key: "COPY_SCHEMA", label: "스키마 복제", sub: "pg_dump --schema-only | psql", sec: 5 },
|
|
{ key: "COPY_DATA", label: "템플릿 데이터 복사", sub: "선택된 그룹의 공통 데이터", sec: 12 },
|
|
{ key: "CREATE_ADMIN", label: "관리자 계정 생성", sub: "BCrypt · user_info", sec: 3 },
|
|
{ key: "FINALIZE", label: "마무리", sub: "db_status=active · 캐시 무효화", sec: 2 },
|
|
];
|
|
|
|
type RowStatus = "pending" | "running" | "done" | "failed";
|
|
|
|
export default function Step4Run({
|
|
state,
|
|
onDone,
|
|
}: {
|
|
state: Record<string, any>;
|
|
onDone: (result: { success: boolean; subdomain?: string; companyCode?: string; error?: string }) => void;
|
|
}) {
|
|
const [jobId, setJobId] = useState<string | null>(null);
|
|
const [status, setStatus] = useState<Record<string, any>>({
|
|
status: "in_progress",
|
|
currentStep: "",
|
|
progress: 0,
|
|
});
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
const [log, setLog] = useState<string[]>([]);
|
|
const [startedAt] = useState<number>(() => Date.now());
|
|
const pollRef = useRef<NodeJS.Timeout | null>(null);
|
|
const startedRef = useRef(false);
|
|
const prevStepRef = useRef<string>("");
|
|
const logScrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
function appendLog(line: string) {
|
|
const hhmmss = new Date().toTimeString().slice(0, 8);
|
|
setLog((lg) => [...lg, `[${hhmmss}] ${line}`]);
|
|
}
|
|
|
|
// 로그 auto-scroll
|
|
useEffect(() => {
|
|
const el = logScrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [log]);
|
|
|
|
useEffect(() => {
|
|
if (startedRef.current) return;
|
|
startedRef.current = true;
|
|
appendLog(`▸ START provisioning · ${state.company_code || "—"}`);
|
|
appendLog(`· subdomain=${state.subdomain}`);
|
|
appendLog(`· db_prefix=${state.db_prefix}`);
|
|
(async () => {
|
|
try {
|
|
const payload: CreateCompanyRequest = {
|
|
company_code: state.company_code,
|
|
company_name: state.company_name,
|
|
subdomain: state.subdomain,
|
|
db_prefix: state.db_prefix,
|
|
business_registration_number: state.business_registration_number,
|
|
representative_name: state.representative_name,
|
|
representative_phone: state.representative_phone,
|
|
email: state.email,
|
|
website: state.website,
|
|
address: state.address,
|
|
selected_groups: state.selected_groups || [],
|
|
initial_password: state.initial_password,
|
|
force_password_change: state.force_password_change !== false,
|
|
};
|
|
const resp = await createCompany(payload);
|
|
setJobId(resp.provisioning_id);
|
|
appendLog(`✓ accepted · job_id=${resp.provisioning_id}`);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || "요청 실패";
|
|
setCreateError(msg);
|
|
appendLog(`✗ FAILED · ${msg}`);
|
|
onDone({ success: false, error: msg });
|
|
}
|
|
})();
|
|
return () => {
|
|
if (pollRef.current) clearTimeout(pollRef.current);
|
|
};
|
|
// eslint-disable-next-line
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!jobId) return;
|
|
let cancelled = false;
|
|
|
|
async function tick() {
|
|
try {
|
|
const s = await getProvisioningStatus(jobId!);
|
|
if (cancelled) return;
|
|
setStatus(s);
|
|
|
|
if (s.currentStep && s.currentStep !== prevStepRef.current) {
|
|
const disp = DISPLAY_STEPS.find((d) => d.key === s.currentStep);
|
|
appendLog(`▸ ${s.currentStep}${disp ? ` · ${disp.label}` : ""}`);
|
|
prevStepRef.current = s.currentStep;
|
|
}
|
|
|
|
if (s.status === "completed") {
|
|
appendLog(`━ READY · https://${s.subdomain || state.subdomain}.invyone.com`);
|
|
onDone({ success: true, subdomain: s.subdomain || state.subdomain, companyCode: s.companyCode });
|
|
return;
|
|
}
|
|
if (s.status === "failed") {
|
|
appendLog(`✗ FAILED · ${s.failedStep || "—"} · ${s.errorMessage || "—"}`);
|
|
onDone({ success: false, error: s.errorMessage || "프로비저닝 실패" });
|
|
return;
|
|
}
|
|
} catch {
|
|
// 네트워크 일시 장애 → 다음 틱에서 재시도
|
|
}
|
|
pollRef.current = setTimeout(tick, 2000);
|
|
}
|
|
tick();
|
|
return () => {
|
|
cancelled = true;
|
|
if (pollRef.current) clearTimeout(pollRef.current);
|
|
};
|
|
// eslint-disable-next-line
|
|
}, [jobId]);
|
|
|
|
const current = status?.currentStep as string | undefined;
|
|
const failed = status?.status === "failed";
|
|
const done = status?.status === "completed";
|
|
|
|
function rowStatus(key: string): RowStatus {
|
|
if (!current && !done && !failed) return "pending";
|
|
if (done) return "done";
|
|
const idx = DISPLAY_STEPS.findIndex((s) => s.key === key);
|
|
const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current);
|
|
if (failed && status.failedStep === key) return "failed";
|
|
if (idx < curIdx) return "done";
|
|
if (idx === curIdx) return failed ? "failed" : "running";
|
|
return "pending";
|
|
}
|
|
|
|
const progress: number = useMemo(() => {
|
|
if (done) return 100;
|
|
if (typeof status?.progress === "number") return Math.max(0, Math.min(100, Math.round(status.progress)));
|
|
if (!current) return 0;
|
|
const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current);
|
|
if (curIdx < 0) return 0;
|
|
return Math.round(((curIdx + 0.5) / DISPLAY_STEPS.length) * 100);
|
|
}, [done, current, status?.progress]);
|
|
|
|
const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
|
|
const variant: "running" | "success" | "failed" = failed ? "failed" : done ? "success" : "running";
|
|
const barLabel = variant === "success" ? "완료" : variant === "failed" ? "중단" : "진행 중";
|
|
const accent =
|
|
variant === "success" ? "rgb(var(--v5-green-rgb))" : variant === "failed" ? "var(--v5-red)" : "var(--v5-cyan)";
|
|
const accentRgb =
|
|
variant === "success" ? "var(--v5-green-rgb)" : variant === "failed" ? "var(--v5-red-rgb)" : "var(--v5-cyan-rgb)";
|
|
|
|
return (
|
|
<div
|
|
className="wiz-step"
|
|
style={{
|
|
padding: "1.6rem 1.8rem",
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 360px",
|
|
gap: "1.8rem",
|
|
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
}}
|
|
>
|
|
<style>{`
|
|
@keyframes s4Orb {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.05); }
|
|
}
|
|
@keyframes s4OrbGlow {
|
|
0%, 100% { opacity: 0.7; transform: scale(1); }
|
|
50% { opacity: 1; transform: scale(1.15); }
|
|
}
|
|
@keyframes s4OrbRing1 {
|
|
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
|
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
|
}
|
|
@keyframes s4OrbRing2 {
|
|
0% { transform: translate(-50%, -50%) rotate(360deg); }
|
|
100% { transform: translate(-50%, -50%) rotate(0deg); }
|
|
}
|
|
@keyframes s4StepIn {
|
|
from { opacity: 0; transform: translateX(-8px); }
|
|
to { opacity: 1; transform: translateX(0); }
|
|
}
|
|
@keyframes s4SuccessBurst {
|
|
0% { transform: scale(0.2); opacity: 0; }
|
|
60% { transform: scale(1.2); opacity: 1; }
|
|
100% { transform: scale(1); opacity: 1; }
|
|
}
|
|
@keyframes s4SuccessRay {
|
|
0% { opacity: 0; transform: scaleY(0); }
|
|
50% { opacity: 0.9; transform: scaleY(1); }
|
|
100% { opacity: 0; transform: scaleY(1); }
|
|
}
|
|
@keyframes s4Shake {
|
|
0%, 100% { transform: translateX(0); }
|
|
20% { transform: translateX(-4px); }
|
|
40% { transform: translateX(4px); }
|
|
60% { transform: translateX(-3px); }
|
|
80% { transform: translateX(3px); }
|
|
}
|
|
@keyframes s4Sweep {
|
|
0% { left: -40%; }
|
|
100% { left: 100%; }
|
|
}
|
|
@keyframes s4LogLine {
|
|
from { opacity: 0; transform: translateY(-2px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.s4-step-row {
|
|
animation: s4StepIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
}
|
|
.s4-log-line {
|
|
animation: s4LogLine 0.2s ease-out both;
|
|
}
|
|
.s4-shake {
|
|
animation: s4Shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97);
|
|
}
|
|
`}</style>
|
|
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.18em",
|
|
textTransform: "uppercase",
|
|
color: accent,
|
|
marginBottom: 8,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
04 · 생성 진행
|
|
</div>
|
|
<h2
|
|
style={{
|
|
fontSize: "1.4rem",
|
|
fontWeight: 800,
|
|
letterSpacing: "-0.02em",
|
|
margin: 0,
|
|
color: "var(--v5-text)",
|
|
}}
|
|
>
|
|
{variant === "success" ? "회사 생성 완료" : variant === "failed" ? "생성 실패" : "회사 생성 중"}
|
|
</h2>
|
|
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, fontWeight: 500, lineHeight: 1.55 }}>
|
|
{variant === "success" && (
|
|
<>
|
|
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>
|
|
{state.subdomain}.invyone.com
|
|
</b>{" "}
|
|
에서 접속 가능합니다. 회사에 URL 과 초기 비밀번호를 전달하세요.
|
|
</>
|
|
)}
|
|
{variant === "failed" && <>오류가 발생했습니다. 로그를 확인 후 롤백/재시도를 진행하세요.</>}
|
|
{variant === "running" && <>브라우저를 닫아도 서버에서 계속 진행됩니다.</>}
|
|
</div>
|
|
{createError && (
|
|
<div style={{ marginTop: 8, fontSize: "0.78rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>
|
|
{createError}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 중앙 Orb + 큰 진행 바 ── */}
|
|
<div
|
|
className={variant === "failed" ? "s4-shake" : ""}
|
|
style={{
|
|
marginTop: "1.3rem",
|
|
padding: "1.6rem 1.4rem 1.25rem",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 14,
|
|
background:
|
|
variant === "running"
|
|
? "linear-gradient(180deg, rgba(var(--v5-cyan-rgb), 0.04), var(--v5-surface-solid))"
|
|
: variant === "success"
|
|
? "linear-gradient(180deg, rgba(var(--v5-green-rgb), 0.05), var(--v5-surface-solid))"
|
|
: "linear-gradient(180deg, rgba(var(--v5-red-rgb), 0.04), var(--v5-surface-solid))",
|
|
boxShadow: `0 0 22px rgba(${accentRgb}, 0.08)`,
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Orb */}
|
|
<div
|
|
style={{
|
|
position: "relative",
|
|
height: 140,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginBottom: "1.1rem",
|
|
}}
|
|
>
|
|
{/* outer glow */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
left: "50%",
|
|
width: 180,
|
|
height: 180,
|
|
borderRadius: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
background: `radial-gradient(circle, rgba(${accentRgb}, 0.14) 0%, rgba(${accentRgb}, 0) 70%)`,
|
|
animation: variant === "running" ? "s4OrbGlow 2.4s ease-in-out infinite" : "none",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
|
|
{/* rotating ring (single solid arc) */}
|
|
{variant === "running" && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
left: "50%",
|
|
width: 110,
|
|
height: 110,
|
|
borderRadius: "50%",
|
|
border: "1.5px solid rgba(var(--v5-cyan-rgb), 0.12)",
|
|
borderTopColor: "rgba(var(--v5-cyan-rgb), 0.85)",
|
|
animation: "s4OrbRing2 2.4s linear infinite",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* success rays */}
|
|
{variant === "success" &&
|
|
Array.from({ length: 8 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
left: "50%",
|
|
width: 2,
|
|
height: 46,
|
|
marginLeft: -1,
|
|
marginTop: -46,
|
|
background: "linear-gradient(to top, rgba(var(--v5-green-rgb), 0.7), transparent)",
|
|
transformOrigin: "bottom center",
|
|
transform: `rotate(${i * 45}deg)`,
|
|
animation: `s4SuccessRay 1.2s ease-out ${0.15 + i * 0.04}s both`,
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* core */}
|
|
<div
|
|
style={{
|
|
position: "relative",
|
|
width: 78,
|
|
height: 78,
|
|
borderRadius: "50%",
|
|
background:
|
|
variant === "success"
|
|
? "linear-gradient(135deg, rgb(var(--v5-green-rgb)), #5de6b5)"
|
|
: variant === "failed"
|
|
? "linear-gradient(135deg, var(--v5-red), #ff6b6b)"
|
|
: "linear-gradient(135deg, var(--v5-cyan), var(--v5-primary))",
|
|
boxShadow: `0 0 18px rgba(${accentRgb}, 0.4), inset 0 -4px 10px rgba(0, 0, 0, 0.18)`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "#fff",
|
|
animation:
|
|
variant === "running"
|
|
? "s4Orb 2.4s ease-in-out infinite"
|
|
: variant === "success"
|
|
? "s4SuccessBurst 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both"
|
|
: "none",
|
|
}}
|
|
>
|
|
{variant === "success" ? (
|
|
<Check size={36} strokeWidth={2.5} />
|
|
) : variant === "failed" ? (
|
|
<X size={36} strokeWidth={2.5} />
|
|
) : (
|
|
<Rocket size={30} strokeWidth={1.75} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 진행 바 */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
justifyContent: "space-between",
|
|
marginBottom: "0.55rem",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.15em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
{variant === "running" && (
|
|
<span
|
|
style={{
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: "50%",
|
|
background: accent,
|
|
boxShadow: `0 0 4px ${accent}`,
|
|
animation: "pulsedot 1.4s ease-in-out infinite",
|
|
}}
|
|
/>
|
|
)}
|
|
{barLabel}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "1.75rem",
|
|
fontWeight: 800,
|
|
letterSpacing: "-0.03em",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
color: accent,
|
|
fontVariantNumeric: "tabular-nums",
|
|
}}
|
|
>
|
|
{progress}
|
|
<span style={{ fontSize: "0.9rem", color: "var(--v5-text-muted)", marginLeft: 1 }}>%</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
height: 8,
|
|
borderRadius: 4,
|
|
background: "var(--v5-bg-subtle)",
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
border: "1px solid var(--v5-border)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
width: `${progress}%`,
|
|
background:
|
|
variant === "success"
|
|
? "linear-gradient(90deg, rgb(var(--v5-green-rgb)), #5de6b5)"
|
|
: variant === "failed"
|
|
? "linear-gradient(90deg, var(--v5-red), #ff6b6b)"
|
|
: "linear-gradient(90deg, var(--v5-cyan), var(--v5-primary))",
|
|
boxShadow: `0 0 8px rgba(${accentRgb}, 0.35)`,
|
|
transition: "width 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
position: "relative",
|
|
}}
|
|
/>
|
|
{variant === "running" && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
bottom: 0,
|
|
width: "40%",
|
|
background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent)",
|
|
animation: "s4Sweep 1.6s ease-in-out infinite",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
style={{
|
|
marginTop: "0.7rem",
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
<span>작업번호 · {jobId || "—"}</span>
|
|
<span>
|
|
경과 {elapsedSec}s
|
|
{variant === "running" && " · 진행 중"}
|
|
{variant === "success" && " · 완료"}
|
|
{variant === "failed" && " · 실패"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 단계 타임라인 */}
|
|
<div
|
|
style={{
|
|
marginTop: "1.1rem",
|
|
padding: "0.5rem 1.2rem",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 11,
|
|
background: "var(--v5-surface-solid)",
|
|
}}
|
|
>
|
|
{DISPLAY_STEPS.map((s, i) => (
|
|
<RunRow
|
|
key={s.key}
|
|
step={s}
|
|
idx={i}
|
|
status={rowStatus(s.key)}
|
|
isLast={i === DISPLAY_STEPS.length - 1}
|
|
dbName={`${state.db_prefix || "___"}_invyone`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* 실패 에러 메시지 */}
|
|
{failed && (
|
|
<div
|
|
style={{
|
|
marginTop: "1.1rem",
|
|
padding: "1rem 1.15rem",
|
|
border: "1px solid rgba(var(--v5-red-rgb), 0.35)",
|
|
background: "rgba(var(--v5-red-rgb), 0.05)",
|
|
borderRadius: 11,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
fontWeight: 700,
|
|
color: "var(--v5-red)",
|
|
letterSpacing: "0.12em",
|
|
textTransform: "uppercase",
|
|
marginBottom: 8,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 7,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
<AlertOctagon size={14} /> 오류 · {status?.failedStep || "—"}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.82rem",
|
|
padding: "0.7rem 0.8rem",
|
|
background: "var(--v5-bg)",
|
|
borderRadius: 7,
|
|
border: "1px solid var(--v5-border)",
|
|
color: "var(--v5-text)",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
overflowX: "auto",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{status?.errorMessage || createError || "—"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 성공 접속 카드 */}
|
|
{done && (
|
|
<div
|
|
style={{
|
|
marginTop: "1.1rem",
|
|
padding: "1.15rem 1.3rem",
|
|
border: "1px solid rgba(var(--v5-green-rgb), 0.4)",
|
|
background: "linear-gradient(135deg, rgba(var(--v5-green-rgb), 0.08), rgba(var(--v5-green-rgb), 0.03))",
|
|
borderRadius: 12,
|
|
boxShadow: "0 0 16px rgba(var(--v5-green-rgb), 0.08)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "1rem",
|
|
animation: "s4SuccessBurst 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both 0.2s",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 12,
|
|
background: "linear-gradient(135deg, rgb(var(--v5-green-rgb)), #5de6b5)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "#fff",
|
|
boxShadow: "0 0 12px rgba(var(--v5-green-rgb), 0.3)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Sparkles size={22} strokeWidth={1.75} />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
fontWeight: 700,
|
|
color: "rgb(var(--v5-green-rgb))",
|
|
letterSpacing: "0.08em",
|
|
}}
|
|
>
|
|
접속 준비 완료
|
|
</div>
|
|
<div style={{ fontSize: "1rem", fontWeight: 700, marginTop: 3, color: "var(--v5-text)" }}>
|
|
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-cyan)" }}>
|
|
{state.subdomain}.invyone.com
|
|
</span>
|
|
<span style={{ color: "var(--v5-text-sec)", fontWeight: 400 }}> 접속 가능</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.78rem",
|
|
color: "var(--v5-text-muted)",
|
|
marginTop: 3,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
로그인: {state.db_prefix}_admin / [Step 3 에서 복사한 비밀번호]
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 우측: 터미널 로그 콘솔 ── */}
|
|
<div style={{ alignSelf: "start" }}>
|
|
<div
|
|
style={{
|
|
background: "#0a0a0c",
|
|
borderRadius: 11,
|
|
border: "1px solid var(--v5-border)",
|
|
overflow: "hidden",
|
|
boxShadow: "0 0 16px rgba(0, 0, 0, 0.25)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "0.75rem 0.95rem",
|
|
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.7rem",
|
|
letterSpacing: "0.15em",
|
|
textTransform: "uppercase",
|
|
color: "#a8a8b0",
|
|
fontWeight: 600,
|
|
background: "linear-gradient(180deg, #111114, #0a0a0c)",
|
|
}}
|
|
>
|
|
<Terminal size={13} />
|
|
<span
|
|
style={{
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: "50%",
|
|
background: accent,
|
|
boxShadow: `0 0 8px ${accent}`,
|
|
animation: variant === "running" ? "pulsedot 1.4s ease-in-out infinite" : "none",
|
|
}}
|
|
/>
|
|
생성 로그
|
|
<span style={{ flex: 1 }} />
|
|
<span style={{ fontSize: "0.62rem", color: "#6a6a72", letterSpacing: "0.08em" }}>
|
|
{log.length} lines
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={logScrollRef}
|
|
style={{
|
|
padding: "0.75rem 0.95rem",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.72rem",
|
|
lineHeight: 1.7,
|
|
color: "#d4d4dc",
|
|
maxHeight: 480,
|
|
overflow: "auto",
|
|
whiteSpace: "pre-wrap",
|
|
background:
|
|
"radial-gradient(ellipse at top, rgba(var(--v5-cyan-rgb), 0.04), transparent 60%), #0a0a0c",
|
|
}}
|
|
>
|
|
{log.length === 0 ? (
|
|
<span style={{ color: "#70707a" }}>(로그 없음)</span>
|
|
) : (
|
|
log.map((l, i) => (
|
|
<div key={i} className="s4-log-line" style={{ color: colorizeLog(l) }}>
|
|
{l}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function colorizeLog(line: string): string {
|
|
if (line.includes("✓") || line.includes("━ READY")) return "#5de6b5";
|
|
if (line.includes("✗") || line.includes("FAILED")) return "#ff8a8a";
|
|
if (line.includes("▸")) return "#8ce7ff";
|
|
return "#d4d4dc";
|
|
}
|
|
|
|
function RunRow({
|
|
step,
|
|
idx,
|
|
status,
|
|
isLast,
|
|
dbName,
|
|
}: {
|
|
step: { key: string; label: string; sub: string; sec: number };
|
|
idx: number;
|
|
status: RowStatus;
|
|
isLast: boolean;
|
|
dbName: string;
|
|
}) {
|
|
const isDone = status === "done";
|
|
const isActive = status === "running";
|
|
const isFailed = status === "failed";
|
|
const isPending = status === "pending";
|
|
const barColor = isFailed
|
|
? "var(--v5-red)"
|
|
: isDone
|
|
? "rgb(var(--v5-green-rgb))"
|
|
: isActive
|
|
? "var(--v5-cyan)"
|
|
: "var(--v5-border)";
|
|
|
|
return (
|
|
<div
|
|
className="s4-step-row"
|
|
style={{
|
|
position: "relative",
|
|
display: "grid",
|
|
gridTemplateColumns: "40px 1fr 100px",
|
|
gap: "1rem",
|
|
padding: "0.85rem 0",
|
|
alignItems: "center",
|
|
animationDelay: `${idx * 60}ms`,
|
|
}}
|
|
>
|
|
{/* circle + connecting line */}
|
|
<div
|
|
style={{
|
|
position: "relative",
|
|
height: 38,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{!isLast && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 32,
|
|
bottom: -18,
|
|
width: 2,
|
|
background: isDone ? "rgb(var(--v5-green-rgb))" : "var(--v5-border)",
|
|
left: 19,
|
|
transition: "background 0.4s ease",
|
|
}}
|
|
/>
|
|
)}
|
|
<div
|
|
style={{
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: "50%",
|
|
background: isFailed
|
|
? "var(--v5-red)"
|
|
: isDone
|
|
? "rgb(var(--v5-green-rgb))"
|
|
: isActive
|
|
? "var(--v5-cyan)"
|
|
: "var(--v5-bg)",
|
|
border: `2px solid ${barColor}`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: isPending ? "var(--v5-text-muted)" : "#fff",
|
|
boxShadow: isActive
|
|
? "0 0 10px rgba(var(--v5-cyan-rgb), 0.35)"
|
|
: isFailed
|
|
? "0 0 10px rgba(var(--v5-red-rgb), 0.3)"
|
|
: isDone
|
|
? "0 0 6px rgba(var(--v5-green-rgb), 0.25)"
|
|
: "none",
|
|
transition: "all 0.35s cubic-bezier(0.4,0,0.2,1)",
|
|
position: "relative",
|
|
zIndex: 1,
|
|
flexShrink: 0,
|
|
animation: isActive ? "wizPulseRing 1.8s ease-out infinite" : "none",
|
|
}}
|
|
>
|
|
{isDone && <Check size={15} strokeWidth={2.5} />}
|
|
{isFailed && <X size={15} strokeWidth={2.5} />}
|
|
{isActive && (
|
|
<span
|
|
style={{
|
|
width: 13,
|
|
height: 13,
|
|
border: "2.5px solid rgba(255,255,255,0.35)",
|
|
borderTopColor: "#fff",
|
|
borderRadius: "50%",
|
|
}}
|
|
className="spin"
|
|
/>
|
|
)}
|
|
{isPending && (
|
|
<span
|
|
style={{
|
|
fontSize: "0.78rem",
|
|
fontWeight: 700,
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
{idx + 1}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.92rem",
|
|
fontWeight: isActive ? 700 : 600,
|
|
letterSpacing: "-0.01em",
|
|
color: isFailed
|
|
? "var(--v5-red)"
|
|
: isActive
|
|
? "var(--v5-cyan)"
|
|
: isDone
|
|
? "var(--v5-text)"
|
|
: "var(--v5-text-muted)",
|
|
transition: "color 0.3s ease",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
{step.label}
|
|
{isActive && (
|
|
<Zap
|
|
size={12}
|
|
style={{ color: "var(--v5-cyan)", animation: "pulsedot 1.4s ease-in-out infinite" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
marginTop: 3,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{step.sub.replace("%db%", dbName)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
textAlign: "right",
|
|
fontSize: "0.76rem",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontWeight: 600,
|
|
color: isFailed
|
|
? "var(--v5-red)"
|
|
: isDone
|
|
? "rgb(var(--v5-green-rgb))"
|
|
: isActive
|
|
? "var(--v5-cyan)"
|
|
: "var(--v5-text-muted)",
|
|
transition: "color 0.3s ease",
|
|
}}
|
|
>
|
|
{isDone && "완료"}
|
|
{isActive && "진행 중…"}
|
|
{isPending && "대기"}
|
|
{isFailed && "실패"}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|